From 8eddd66adeedcd0b3f005d40fb8def5a73e15133 Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Sat, 12 Sep 2026 15:39:53 +0800 Subject: [PATCH 1/2] Implement Blackboard Classic submission via validated HTTP form --- CHANGELOG.md | 12 ++ README.md | 17 +- docs/ARCHITECTURE.md | 8 +- docs/MIGRATION.md | 8 +- docs/OUTPUT.md | 6 + docs/SERVICES.md | 33 ++- src/cli.ts | 195 +---------------- src/services/blackboard-assignment-form.ts | 194 +++++++++++++++++ src/services/blackboard-submission.ts | 108 ++++++++++ src/services/blackboard.ts | 65 +++--- src/sso/cas.ts | 1 + src/test/blackboard_form_cli.test.ts | 49 +++++ src/test/blackboard_form_submission.test.ts | 225 ++++++++++++++++++++ src/test/blackboard_submission.test.ts | 4 +- src/test/fixtures/blackboard-form-fetch.ts | 67 ++++++ 15 files changed, 753 insertions(+), 239 deletions(-) create mode 100644 src/services/blackboard-assignment-form.ts create mode 100644 src/services/blackboard-submission.ts create mode 100644 src/test/blackboard_form_cli.test.ts create mode 100644 src/test/blackboard_form_submission.test.ts create mode 100644 src/test/fixtures/blackboard-form-fetch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9fffe..783f80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Fixed + +- Submit Classic/Original Blackboard assignments with a fresh, target-validated + HTTP form and one multipart POST using the CAS session. Retain reviewed hashes, + confirmation and new-attempt read-back; no Playwright or public REST upload + authorization is required. Text/comments use the form's exposed editor fields. +- Handle an initial attempts-list 404 as empty only after verifying the exact + accessible assignment and its blank first-submission view form. Other errors + and later-page failures remain errors. +- Preserve submission failure stage and sanitized upstream status/path. Never + automatically replay a submission POST, including across HTTP redirects. + ## [0.11.0] - 2026-09-07 ### Added diff --git a/README.md b/README.md index 904b565..efb6c93 100644 --- a/README.md +++ b/README.md @@ -453,8 +453,10 @@ review instead of being promoted to a definite requirement match. ## Current limitations -- Blackboard submission follows official Learn REST attempt/upload endpoints - and is fixture-tested, but it has not yet performed a real Blackboard write. +- Blackboard submission uses a Classic/Original HTTP form with the CAS session + and REST attempt read-back. An individual Original file resubmission passed + live CLI submission and read-back on 2026-09-11. Text submission and the + first-submission 404 fallback remain fixture-tested only. - Student-submitted attempt files are separate from teacher-provided content attachments. `bb attempt-files` lists one attempt's files, and `bb attempt-download` downloads one of them to an explicit local path when @@ -469,11 +471,12 @@ review instead of being promoted to a definite requirement match. an interactive slide CAPTCHA. The CLI will not bypass that challenge. A previously stored Blackboard native calendar link can still be fetched without CAS. -- Blackboard submission stays on the official Learn REST path: file attachments - remain limited to Classic/Original assignment attempts, and supported - assignment targets can also submit text through the attempt payload. The CLI - does not scrape or silently fall back to the legacy `uploadAssignment` HTML - form. +- Blackboard submission remains entirely in the CLI: a fresh form nonce and + exact target are validated before one multipart POST to `uploadAssignment`. + It supports individual Classic/Original file or text submissions with no + Playwright dependency in the write path. Ultra/group assignments and silent + draft resumption are unsupported. An attempts-list 404 becomes empty only + after a matching blank first-submission view form confirms that state. - Blackboard `bb message-send preview/apply` stays on the official course message create endpoint, binds apply to the previewed SHA-256 plus exact recipient IDs, and verifies the created message by Sent-folder read-back. It diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index df97eb7..2361b4e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,7 +140,13 @@ commands for them. - Remote mutations cover TIS enroll/cart/drop/bid, Blackboard submission, eHall and library booking create/cancel, and PMS queue upload/delete. Every path requires `--confirm`; file-bound uploads additionally require the - previewed SHA-256. These paths are protocol-fixture-tested, not live-written. + previewed SHA-256. These paths have protocol-fixture coverage. An authorized + individual Blackboard Original file resubmission additionally passed live + submission and REST read-back on 2026-09-11. +- Blackboard Original submission reads a fresh assignment form with CAS cookies + and sends one multipart HTTP POST. Nonces stay in memory; redirects cannot + replay the POST. REST read-back must identify one new submitted attempt and + verify its file/text and optional comment before reporting success. - eHall booking and library-booking create previews attempt exact slot availability checks first and fail closed when the live evidence is missing, malformed, or ambiguous. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index afd998d..2ece346 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -5,8 +5,10 @@ Ports are fixture-driven and land in this standalone repository. Opt-in, read-only live smoke tests on 2026-08-26 covered TIS enrollment reads, Blackboard courses, WS programs, eHall rooms, and library-booking identity, summary, lab, and reservation-count reads. PMS remained blocked by its -campus-network gate. All newly added mutation paths remain fixture-tested only; -no real account mutation was attempted while completing this expansion. +campus-network gate. That expansion did not perform real account mutations. +A later authorized individual Blackboard Original file resubmission passed live +CLI submission and REST read-back on 2026-09-11; other mutation paths remain +fixture-tested only. | Area | Read operations | Mutations | Status | Priority | | --- | --- | --- | --- | --- | @@ -25,7 +27,7 @@ no real account mutation was attempted while completing this expansion. | Academic snapshots | normalized TIS state with optional Blackboard deadlines; `academic changes`; one-shot `academic watch` | guarded versioned snapshot files | Implemented with digest verification, no-overwrite defaults, and no remote write behavior | P1 | | Resources | built-in campus resource registry and search | none | Implemented | P1 | | Wi-Fi | current association and recent macOS SUSTC Wi-Fi events | none | Implemented on macOS only | P1 | -| Blackboard | courses, content, roster, course messages, course-message send preview/apply, discussions, teacher-provided attachment listing/download, per-course and cross-course assignments, cross-course grades, deadlines, calendar REST reads, search, attempts, native calendar-link storage/fetch | guarded local sync, optional ICS write, Classic/Original file submission plus supported text submission | CLI CAS login and courses read live-smoked; roster, course-message, discussion-group discovery, and course-message send now follow official current Learn REST endpoints. Message send validates exact roster recipients, binds apply to the previewed text SHA-256, and verifies the created Sent-folder record. Calendar reads, native feed-link storage/fetch, local download/sync, and the hash-bound submission workflow use official Learn REST/BBML paths or keyring fixtures and remain conservatively documented | P2 | +| Blackboard | courses, content, roster, course messages, course-message send preview/apply, discussions, teacher-provided attachment listing/download, per-course and cross-course assignments, cross-course grades, deadlines, calendar REST reads, search, attempts, native calendar-link storage/fetch | guarded local sync, optional ICS write, individual Classic/Original file and text submission | CLI CAS login and courses read live-smoked; roster, course-message, discussion-group discovery, and course-message send now follow official current Learn REST endpoints. Message send validates exact roster recipients, binds apply to the previewed text SHA-256, and verifies the created Sent-folder record. Calendar reads, native feed-link storage/fetch and local download/sync use Learn REST/BBML paths or keyring fixtures. Assignment submission uses a fresh Classic/Original HTTP form with CAS cookies, hash binding and REST read-back; individual Original file resubmission passed a live check on 2026-09-11, while text remains fixture-tested | P2 | | Library catalog | Primo search/detail reads, browser fallback transport | none | Implemented with direct public HTTP plus manual `--browser [--interactive]` fallback; browser auth stays user-completed and cookies are not persisted by the CLI | P2 | | Library booking | account state, idle summary, labs, rooms, reservation counts, reservations | guarded create/cancel | Login, account, summary, labs, and count live-smoked; previews now use conservative exact-availability checks and fail closed when the slot cannot be proved safe | P2 | | E-Hall booking | redacted user profile, rooms, meetings | guarded create/cancel | Login and rooms read live-smoked; previews now use conservative exact day/time availability checks and fail closed on unreadable overlap state | P2 | diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index c4d6088..f80f8db 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -87,3 +87,9 @@ names rather than raw download URLs. A successful `bb attempt-download` result contains the selected file identity, the absolute destination path, byte count, content type, and SHA-256. The `files` read-back inside a successful `bb submit apply` result uses the same URL-free file identity shape. + +Blackboard submission preparation failures report `submissionPostSent: false`. +After a POST starts, an unverified result returns +`BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN` with exit code 5. Diagnostics retain the +stage and available upstream status/path, excluding form nonces, response bodies, +and credential-bearing URL queries. diff --git a/docs/SERVICES.md b/docs/SERVICES.md index 6bdef62..487cb4b 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -21,7 +21,7 @@ while the CLI already supplies that transport for a specific command family. | Service | Availability | Auth | CLI surface today | Notes | | --- | --- | --- | --- | --- | -| `blackboard` | `adapter_required` | CAS cookie session | `bb user`, `bb courses`, `bb content`, `bb tree`, `bb types`, `bb attachments`, `bb download`, `bb roster`, `bb message-folders`, `bb messages`, `bb message-participants`, `bb message-send preview/apply`, `bb discussions`, `bb discussion-groups`, `bb discussion`, `bb discussion-replies`, `bb assignments`, `bb grades`, `bb attempt-files`, `bb attempt-download`, `bb announcements`, `bb deadlines`, `bb calendar`, `bb search`, `bb sync`, `bb attempts`, `bb submit preview`, `bb submit apply`, `bb calendar-link set/show/fetch/delete` | CLI CAS login and announcement aggregation passed opt-in read-only live smoke tests. Recursive content-tree reads via `bb tree`; course roster reads via `bb roster`; course-message reads via `bb message-folders` / `bb messages` / `bb message-participants`; and discussion/forum reads via `bb discussions` / `bb discussion-groups` / `bb discussion` / `bb discussion-replies` when Blackboard exposes a compatible Learn REST discussion surface. For Blackboard Original courses that reject the REST discussion API, `bb discussions` falls back to the HTML discussion-board forum list, `bb discussion` falls back to the HTML thread list, and `bb discussion-replies` falls back to the HTML thread-detail reply list, while group reads and discussion writes that still require the REST surface fail closed with `BLACKBOARD_DISCUSSIONS_UNSUPPORTED`. `bb message-send preview/apply` uses the official Learn REST course-message create endpoint, validates exact recipient IDs against the live course roster, binds apply to the reviewed text SHA-256, and verifies the created message by Sent-folder read-back. Cross-course `bb grades`, cross-course `bb assignments --course ...`, `bb assignments --with-attempts`, `bb assignments --submission-state ...`, `bb deadlines --submission-state ...`, and `bb types` preserve accessible results and record per-course, per-folder, or per-assignment failures as partial output where applicable. Content download/sync supports the official Original endpoint and embedded BBML links. Attempt-file reads expose the authenticated student's submitted files separately from teacher attachments; attempt-file downloads work when Blackboard exposes a usable download endpoint for that record and otherwise fail closed as unavailable. Blackboard message-send and assignment submission remain fixture-tested only: file attachments still follow the Classic/Original attempt-file path, while supported Blackboard assignment targets can also submit text through the official attempt payload. | +| `blackboard` | `adapter_required` | CAS cookie session | `bb user`, `bb courses`, `bb content`, `bb tree`, `bb types`, `bb attachments`, `bb download`, `bb roster`, `bb message-folders`, `bb messages`, `bb message-participants`, `bb message-send preview/apply`, `bb discussions`, `bb discussion-groups`, `bb discussion`, `bb discussion-replies`, `bb assignments`, `bb grades`, `bb attempt-files`, `bb attempt-download`, `bb announcements`, `bb deadlines`, `bb calendar`, `bb search`, `bb sync`, `bb attempts`, `bb submit preview`, `bb submit apply`, `bb calendar-link set/show/fetch/delete` | CLI CAS login and announcement aggregation passed opt-in read-only live smoke tests. Recursive content-tree reads via `bb tree`; course roster reads via `bb roster`; course-message reads via `bb message-folders` / `bb messages` / `bb message-participants`; and discussion/forum reads via `bb discussions` / `bb discussion-groups` / `bb discussion` / `bb discussion-replies` when Blackboard exposes a compatible Learn REST discussion surface. For Blackboard Original courses that reject the REST discussion API, `bb discussions` falls back to the HTML discussion-board forum list, `bb discussion` falls back to the HTML thread list, and `bb discussion-replies` falls back to the HTML thread-detail reply list, while group reads and discussion writes that still require the REST surface fail closed with `BLACKBOARD_DISCUSSIONS_UNSUPPORTED`. `bb message-send preview/apply` uses the official Learn REST course-message create endpoint, validates exact recipient IDs against the live course roster, binds apply to the reviewed text SHA-256, and verifies the created message by Sent-folder read-back. Cross-course `bb grades`, cross-course `bb assignments --course ...`, `bb assignments --with-attempts`, `bb assignments --submission-state ...`, `bb deadlines --submission-state ...`, and `bb types` preserve accessible results and record per-course, per-folder, or per-assignment failures as partial output where applicable. Content download/sync supports the official Original endpoint and embedded BBML links. Attempt-file reads expose the authenticated student's submitted files separately from teacher attachments; attempt-file downloads work when Blackboard exposes a usable download endpoint for that record and otherwise fail closed as unavailable. Blackboard message-send and assignment text submission remain fixture-tested only; individual Original file resubmission passed live CLI submission and REST read-back on 2026-09-11. Individual Classic/Original assignment files and text use a fresh HTTP form with the CAS session and REST read-back; Ultra/group submission is unsupported. | | `booking` | `implemented` | CAS cookie session plus booking bearer token, campus reachability | `booking whoami`, `booking rooms`, `booking my-meetings`, `booking create preview/apply`, `booking cancel preview/apply` | CLI login and room-list read passed an opt-in live smoke test on 2026-08-26. Create preview now checks the live room calendar for the exact day/time and fails closed when overlaps or unreadable calendar state prevent a safe decision. Remote writes still require preview, `--confirm`, and exact read-back. | | `library-catalog` | `implemented` | public HTTP or manual browser session | `library search`, `library detail` | Primo public search/detail normalization is implemented, and `--browser [--interactive]` provides a manual browser-backed fallback. The CLI never fabricates records, never accepts browser credentials, never solves CAPTCHAs, and never persists browser cookies. Some runtimes may still need the browser path because upstream TLS behavior can differ by host. | | `library-booking` | `implemented` | IC booking cookie session, campus reachability | `lib-booking whoami`, `lib-booking home-summary`, `lib-booking labs`, `lib-booking rooms`, `lib-booking reservation-count`, `lib-booking reservations`, `lib-booking create preview/apply`, `lib-booking cancel preview/apply` | Login plus identity, summary, labs, and count reads passed an opt-in live smoke test on 2026-08-26. Create preview now combines room open-times with reservation metadata and fails closed when exact availability cannot be proved safely. Membership and capacity rules remain conservative. | @@ -126,18 +126,31 @@ defaults as `bb download`. If Blackboard exposes only metadata and the official download endpoint still returns `404`, the CLI reports `BLACKBOARD_ATTEMPT_FILE_UNAVAILABLE` instead of a generic transport error. -Blackboard assignment submission uses the official Learn REST APIs: v2 grade -columns and attempts, v1 temporary uploads, and v1 attempt files. Text -submissions can use the official attempt payload on supported assignment -targets. The attempt-file endpoint is still limited by Blackboard to -Classic/Original assignments, which the read-only preflight verifies from both -the content handler and grade-column metadata. +Blackboard assignment submission uses the Classic/Original HTTP form with the +existing CAS cookie session. The CLI reads a fresh form during apply, validates +its target and nonce, and sends one multipart POST to `uploadAssignment`. +No Playwright is involved in this submission path. File and plain-text submissions +are supported; text/comments require the corresponding form editor fields. +Ultra and group assignments are not supported by this form workflow. + +Attempt discovery and read-back use Learn REST. An initial attempts-list `404` +is treated as an empty history only when the exact accessible Classic assignment's +read-only `mode=view` page exposes a matching blank first-submission form. +An inaccessible/ambiguous page, existing draft/history, or later-page failure +remains an error. Preview never requests `action=newAttempt` or sends a POST. `bb submit preview` authenticates but only reads. `bb submit apply` requires `--confirm`, the previewed `--expected-sha256`, a fresh preflight, and a -post-submit read-back of both attempt status and filename. Existing in-progress -attempts are not silently resumed. An uncertain write returns exit code 5 with -`DO_NOT_RETRY_AUTOMATICALLY`; the CLI never falls back to the legacy HTML form. +post-submit read-back of a single new attempt's status and filename or text, +plus comments when supplied. Existing in-progress attempts and drafts are not +silently resumed. An uncertain write returns exit code 5 with +`DO_NOT_RETRY_AUTOMATICALLY`; the CLI does not retry or redirect-replay a POST. +Error diagnostics retain the stage and sanitized upstream status/path without +form nonces or response bodies. On 2026-09-11, an explicitly authorized +individual Original file resubmission passed live CLI submission and REST +read-back: a new NeedsGrading attempt, the expected filename, and a matching +receipt byte count were observed. Text submission and the first-submission +404 fallback remain covered by isolated HTTP fixtures only. ## TIS degree surfaces diff --git a/src/cli.ts b/src/cli.ts index 12a2db5..32df9e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -207,9 +207,9 @@ import { CasSession, type CasServiceConfig } from "./sso/cas.js"; import { BookingSession } from "./services/booking-auth.js"; import { LibraryBookingSession } from "./services/library-booking-auth.js"; import { PmsSession } from "./services/pms-auth.js"; +import { applyBlackboardOriginalSubmission } from "./services/blackboard-submission.js"; import { SERVICE_STATUSES, - attachBlackboardAttemptFile, browseNces, buildPrimoSearchUrl, buildBookingCreatePreview, @@ -221,7 +221,6 @@ import { applyLibraryBookingCreate, applyLibraryBookingCancel, cleanText, - createBlackboardAttempt, createBlackboardBrowserAdapter, createBlackboardCourseMessage, createBlackboardDiscussionMessage, @@ -234,7 +233,6 @@ import { formatBrowserPrimoCatalogDetail, formatBrowserPrimoCatalogSearch, formatServiceStatuses, - getBlackboardAttempt, getBlackboardContentItem, listBlackboardCourseMessageFolders, listBlackboardCourseMessageParticipants, @@ -311,8 +309,6 @@ import { sampleText, listBlackboardAnnouncements, syncBlackboardAttachments, - updateBlackboardAttempt, - uploadBlackboardTemporaryFile, verifyPmsPrintDeletion, verifyPmsPrintUpload, deleteBlackboardCalendarLink, @@ -5591,111 +5587,14 @@ async function runBlackboard( const preflight = await buildBlackboardSubmissionPreflight(adapter, values, target, submission, comment); ensureBlackboardSubmissionAllowed(preflight, values["allow-late"] === true); - const assignment = preflight.assignment; - let createdAttemptId = ""; - let uploadedId = ""; - let stage: "upload" | "create_attempt" | "attach_file" | "submit_attempt" | "verify" = submission.kind === "file" ? "upload" : "create_attempt"; - try { - if (submission.kind === "file") { - stage = "upload"; - const uploaded = await uploadBlackboardTemporaryFile(adapter, submission.file, submission.bytes); - uploadedId = uploaded.id; - } - stage = "create_attempt"; - const attempt = await createBlackboardAttempt(adapter, target.courseId, assignment.id, { - ...(submission.kind === "text" ? { studentSubmission: submission.text } : {}), - ...(comment ? { studentComments: comment } : {}), - }); - createdAttemptId = attempt.id; - if (submission.kind === "file") { - stage = "attach_file"; - await attachBlackboardAttemptFile(adapter, target.courseId, createdAttemptId, { - name: submission.file.name, - uploadId: uploadedId, - }); - } - stage = "submit_attempt"; - const submitted = await updateBlackboardAttempt(adapter, target.courseId, assignment.id, createdAttemptId, { - status: "NeedsGrading", - }); - stage = "verify"; - const snapshot = await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId); - const observedAttempt = snapshot.attempt ?? submitted; - const verification = snapshot.attempt - ? verifyBlackboardSubmission(snapshot.attempt, snapshot.files, submission) - : { - status: "unavailable" as const, - message: "The submitted attempt status could not be read back from Blackboard.", - }; - if (verification.status !== "confirmed") { - throw new CliError( - "Blackboard accepted the submission request, but the read-back verification was inconclusive.", - "BLACKBOARD_SUBMISSION_NOT_CONFIRMED", - 1, - { - courseId: target.courseId, - contentId: assignment.contentId, - columnId: assignment.id, - attemptId: createdAttemptId, - verification, - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); - } - writeBlackboardSubmissionResult(output, preflight, submission, comment, observedAttempt, snapshot.files, verification); - return; - } catch (error) { - if (error instanceof CliError && error.code === "BLACKBOARD_FILE_CHANGED") throw error; - let candidateAttemptIds: string[] = []; - if (!createdAttemptId) { - const drift = stage === "create_attempt" - ? await observeBlackboardAttemptCreation(adapter, target.courseId, assignment.id, preflight.attempts) - : undefined; - candidateAttemptIds = drift?.candidateAttemptIds ?? []; - if (drift?.attempt) createdAttemptId = drift.attempt.id; - } - const snapshot = createdAttemptId - ? await observeBlackboardAttemptSnapshot(adapter, target.courseId, assignment.id, createdAttemptId) - : { files: [] as BlackboardAttemptFile[] }; - const verification = snapshot.attempt - ? verifyBlackboardSubmission(snapshot.attempt, snapshot.files, submission) - : { status: "unavailable" as const, message: "Blackboard did not expose enough read-back state to confirm the submission." }; - if (snapshot.attempt && verification.status === "confirmed") { - writeBlackboardSubmissionResult( - output, - preflight, - submission, - comment, - snapshot.attempt, - snapshot.files, - verification, - true, - ); - return; - } - throw new CliError( - "Blackboard submission outcome is uncertain. Do not retry automatically.", - "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN", - 5, - { - stage, - courseId: target.courseId, - contentId: assignment.contentId, - columnId: assignment.id, - ...(createdAttemptId ? { attemptId: createdAttemptId } : {}), - candidateAttemptIds, - ...(uploadedId ? { uploadId: uploadedId } : {}), - ...(submission.kind === "file" - ? { fileName: submission.file.name } - : { textFile: submission.textFile.absolutePath }), - ...(snapshot.attempt?.status ? { attemptStatus: snapshot.attempt.status } : {}), - observedFiles: snapshot.files.map((entry) => entry.name), - verification, - cause: error instanceof Error ? error.message : String(error), - warning: "DO_NOT_RETRY_AUTOMATICALLY", - }, - ); - } + const result = await applyBlackboardOriginalSubmission(adapter, { + courseId: target.courseId, + contentId: preflight.assignment.contentId, + columnId: preflight.assignment.id, + }, submission, preflight.attempts, { ...(comment ? { comment } : {}) }); + writeBlackboardSubmissionResult(output, preflight, submission, comment, + result.attempt, result.files, result.verification, result.recoveredAfterError); + return; } throw usageError(`Unknown command: ${positionals.join(" ")}`); } @@ -7801,82 +7700,6 @@ function isOptionalBlackboardUploadSettingsError(error: unknown): boolean { return status === 401 || status === 403 || status === 404; } -async function observeBlackboardAttemptSnapshot( - adapter: ServiceAdapter, - courseId: string, - columnId: string, - attemptId: string, -): Promise<{ - attempt?: Awaited>; - files: Awaited>; -}> { - let attempt: Awaited> | undefined; - let files: Awaited> = []; - try { - attempt = await getBlackboardAttempt(adapter, courseId, columnId, attemptId); - } catch { - attempt = undefined; - } - try { - files = await listBlackboardAttemptFiles(adapter, courseId, attemptId); - } catch { - files = []; - } - return { attempt, files }; -} - -async function observeBlackboardAttemptCreation( - adapter: ServiceAdapter, - courseId: string, - columnId: string, - previousAttempts: readonly { id: string }[], -): Promise<{ - attempt?: Awaited>[number]; - candidateAttemptIds: string[]; -}> { - try { - const current = await listBlackboardAttempts(adapter, courseId, columnId); - const previousIds = new Set(previousAttempts.map((attempt) => attempt.id)); - const candidates = current.filter((attempt) => !previousIds.has(attempt.id)); - return { - ...(candidates.length === 1 ? { attempt: candidates[0] } : {}), - candidateAttemptIds: candidates.map((attempt) => attempt.id), - }; - } catch { - return { candidateAttemptIds: [] }; - } -} - -function verifyBlackboardSubmission( - attempt: BlackboardAttempt, - files: Awaited>, - submission: BlackboardCliSubmissionInput, -): BlackboardSubmissionVerification { - if (submission.kind === "file") { - const observedFile = files.some((entry) => entry.name === submission.file.name); - if ((attempt.status === "NeedsGrading" || attempt.status === "Completed") && observedFile) { - return { status: "confirmed", message: "NeedsGrading/Completed and the uploaded filename were read back from Blackboard." }; - } - if (attempt.status) { - return { - status: "not_observed", - message: `Attempt status was ${attempt.status}, but the expected uploaded filename was not fully observed in the read-back state.`, - }; - } - return { status: "unavailable", message: "Blackboard did not expose enough read-back state to confirm the submission." }; - } - if ((attempt.status === "NeedsGrading" || attempt.status === "Completed") && attempt.studentSubmission === submission.text) { - return { status: "confirmed", message: "NeedsGrading/Completed and the submitted text were read back from Blackboard." }; - } - if (attempt.status) { - return { - status: "not_observed", - message: `Attempt status was ${attempt.status}, but the expected submission text was not fully observed in the read-back state.`, - }; - } - return { status: "unavailable", message: "Blackboard did not expose enough read-back state to confirm the submission." }; -} - function writeBlackboardSubmissionResult( output: ReturnType, preflight: BlackboardSubmissionPreviewData, diff --git a/src/services/blackboard-assignment-form.ts b/src/services/blackboard-assignment-form.ts new file mode 100644 index 0000000..cae24b0 --- /dev/null +++ b/src/services/blackboard-assignment-form.ts @@ -0,0 +1,194 @@ +import { createHash } from "node:crypto"; +import { load } from "cheerio"; +import { CliError } from "../core/errors.js"; +import { recordValue, stringValue, type ServiceAdapter } from "./base.js"; +import type { BlackboardSubmissionFile, BlackboardSubmissionText } from "./blackboard.js"; + +const ORIGIN = "https://bb.sustech.edu.cn"; +const ASSIGNMENT_PATH = "/webapps/assignment/uploadAssignment"; +const NONCE = "blackboard.platform.security.NonceUtil.nonce"; + +export interface BlackboardFormTarget { + courseId: string; + contentId: string; +} + +export type BlackboardFormSubmission = + | { kind: "file"; file: BlackboardSubmissionFile; bytes: Uint8Array } + | { kind: "text"; textFile: BlackboardSubmissionText; text: string }; + +interface AssignmentForm { + action: string; + fields: URLSearchParams; + textFields: Set; +} + +/** Only the normal view page may establish that this student has never submitted. */ +export async function confirmBlackboardNoAttempts( + adapter: ServiceAdapter, + target: BlackboardFormTarget, +): Promise { + const response = await adapter.fetch(assignmentUrl(target, { mode: "view" })); + if (!response.ok) return false; + assertAssignmentResponseUrl(response, target); + const html = await response.text(); + const $ = load(html); + if (!/^(?:Upload Assignment|上传作业)\s*[::]/i.test($("title").text().trim())) return false; + if ($("#aggregateGradeForm, [name='attemptId'], [name='attempt_id']").toArray() + .some((element) => element.attribs.id === "aggregateGradeForm" || Boolean($(element).val()))) return false; + if ($("a[href]").toArray().some((element) => { + const url = new URL($(element).attr("href") ?? "", ORIGIN); + return Boolean(url.searchParams.get("attempt_id") || url.searchParams.get("attemptId")); + })) return false; + parseAssignmentForm(html, target); + return true; +} + +/** Uses one cookie session, a fresh form nonce, and exactly one multipart POST. */ +export async function submitBlackboardOriginalForm( + adapter: ServiceAdapter, + target: BlackboardFormTarget, + submission: BlackboardFormSubmission, + options: { comment?: string; onStage?: (stage: "prepare_form" | "submit_form") => void } = {}, +): Promise { + assertSubmissionHash(submission); + options.onStage?.("prepare_form"); + const response = await adapter.fetch(assignmentUrl(target, { action: "newAttempt", group_id: "" })); + if (!response.ok) throw formError("Blackboard could not load the assignment submission form.", response.status); + assertAssignmentResponseUrl(response, target); + const form = parseAssignmentForm(await response.text(), target); + const body = new FormData(); + for (const [name, value] of form.fields) body.append(name, value); + body.set("isAjaxSubmit", "true"); + body.set("dispatch", "submit"); + + if (submission.kind === "file") { + body.set("newFile_attachmentType", "L"); + body.set("newFile_fileId", "new"); + body.set("newFile_artifactFileId", "undefined"); + body.set("newFile_artifactType", "undefined"); + body.set("newFile_artifactTypeResourceKey", "undefined"); + body.set("newFile_linkTitle", submission.file.name); + body.set("newFile_LocalFile0", new Blob([submission.bytes]), submission.file.name); + } else { + setEditorText(body, form, ["studentSubmission.text"], submission.text); + } + if (options.comment) setEditorText(body, form, ["student_comments", "studentComments.text"], options.comment); + + // Reject changed buffers even if a caller changed them while the form GET was pending. + assertSubmissionHash(submission); + options.onStage?.("submit_form"); + const posted = await adapter.fetch(form.action, { + method: "POST", + headers: { "x-requested-with": "XMLHttpRequest", origin: ORIGIN, referer: assignmentUrl(target, { action: "newAttempt", group_id: "" }) }, + body, + redirect: "manual", + }); + if (!posted.ok) throw formError("Blackboard rejected the assignment form POST.", posted.status); + // Never include HTML, nonces, cookies, or the destination query in diagnostics. + let raw: unknown; + try { raw = await posted.json(); } catch { + throw formError("Blackboard returned a non-JSON response to the assignment form POST."); + } + const destination = stringValue(recordValue(raw).destinationUrl); + if (!destination) throw formError("Blackboard did not acknowledge the assignment form submission."); + assertAssignmentUrl(destination, target); +} + +function parseAssignmentForm(html: string, target: BlackboardFormTarget): AssignmentForm { + const $ = load(html); + const form = $("form#uploadAssignmentFormId"); + if (form.length !== 1 + || form.attr("method")?.toLowerCase() !== "post" + || form.attr("enctype")?.toLowerCase() !== "multipart/form-data") { + throw formError("Blackboard did not expose a supported Original assignment submission form."); + } + const action = new URL(form.attr("action") ?? "", ORIGIN); + if (action.origin !== ORIGIN || action.pathname !== ASSIGNMENT_PATH || action.searchParams.get("action") !== "submit" + || action.searchParams.getAll("action").length !== 1 || action.searchParams.get("group_id") + || ["course_id", "content_id", "group_id"].some((key) => action.searchParams.getAll(key).length > 1) + || [...action.searchParams.keys()].some((key) => !["action", "course_id", "content_id", "group_id"].includes(key)) + || action.username || action.password || action.hash) throw formError("Blackboard returned an unexpected assignment form action."); + for (const key of ["course_id", "content_id"] as const) { + const expected = canonicalId(key === "course_id" ? target.courseId : target.contentId); + if (action.searchParams.has(key) && action.searchParams.get(key) !== expected) { + throw formError("Blackboard returned a form action for a different assignment."); + } + } + const fields = new URLSearchParams(); + form.find("input[type=hidden][name]").each((_, element) => { + if (!$(element).is(":disabled")) fields.append($(element).attr("name")!, $(element).attr("value") ?? ""); + }); + for (const [name, expected] of [["course_id", canonicalId(target.courseId)], ["content_id", canonicalId(target.contentId)]]) { + if (fields.getAll(name).length !== 1 || fields.get(name) !== expected) { + throw formError("Blackboard returned a submission form for a different or ambiguous assignment."); + } + } + if (fields.getAll(NONCE).length !== 1 || !fields.get(NONCE) + || fields.getAll("attempt_id").length !== 1 || fields.get("attempt_id") !== "" + || fields.getAll("attemptId").some(Boolean) + || fields.getAll("group_id").some(Boolean) + || form.find("#newFile_chooseLocalFile[type=file]").length !== 1) { + throw formError("Blackboard's form has no usable nonce, resumes an attempt, or is not an individual file/text assignment."); + } + const textFields = new Set(); + form.find("textarea[name]").each((_, element) => { + const name = $(element).attr("name")!; + if (textFields.has(name) || fields.has(name)) throw formError("Blackboard returned ambiguous editor fields."); + textFields.add(name); + const value = $(element).val(); + if (typeof value === "string" && value.trim()) throw formError("Blackboard's form contains an existing draft; it will not be overwritten."); + fields.append(name, ""); + }); + return { action: action.toString(), fields, textFields }; +} + +function setEditorText(body: FormData, form: AssignmentForm, names: string[], text: string): void { + const name = names.find((entry) => form.textFields.has(entry)); + if (!name) throw formError("Blackboard did not expose the required text/comment editor field."); + body.set(name, text.replace(/&/g, "&").replace(//g, ">") + .replace(/\r\n?|\n/g, "
")); +} + +function assertSubmissionHash(submission: BlackboardFormSubmission): void { + const bytes = submission.kind === "file" ? submission.bytes : Buffer.from(submission.text, "utf8"); + const source = submission.kind === "file" ? submission.file : submission.textFile; + if (bytes.byteLength !== source.size || createHash("sha256").update(bytes).digest("hex") !== source.sha256) { + throw new CliError("The Blackboard submission bytes no longer match the reviewed SHA-256.", "BLACKBOARD_FILE_CHANGED", 4); + } +} + +function assignmentUrl(target: BlackboardFormTarget, query: Record): string { + const url = new URL(ASSIGNMENT_PATH, ORIGIN); + url.searchParams.set("course_id", canonicalId(target.courseId)); + url.searchParams.set("content_id", canonicalId(target.contentId)); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + return url.toString(); +} + +function assertAssignmentResponseUrl(response: Response, target: BlackboardFormTarget): void { + if (response.url) assertAssignmentUrl(response.url, target); +} + +function assertAssignmentUrl(value: string, target: BlackboardFormTarget): void { + let url: URL; + try { url = new URL(value, ORIGIN); } catch { throw formError("Blackboard returned an invalid assignment URL."); } + if (url.origin !== ORIGIN || url.pathname !== ASSIGNMENT_PATH || url.username || url.password + || url.searchParams.getAll("course_id").length !== 1 || url.searchParams.get("course_id") !== canonicalId(target.courseId) + || url.searchParams.getAll("content_id").length !== 1 || url.searchParams.get("content_id") !== canonicalId(target.contentId)) { + throw formError("Blackboard returned a URL for an unexpected assignment."); + } +} + +function canonicalId(value: string): string { + if (/^_\d+_1$/.test(value)) return value; + if (/^\d+$/.test(value)) return `_${value}_1`; + throw formError("Original assignment forms require an exact Blackboard course/content ID."); +} + +function formError(message: string, status?: number): CliError { + return new CliError(message, "BLACKBOARD_SUBMISSION_FORM_ERROR", 4, { + path: ASSIGNMENT_PATH, + ...(status !== undefined ? { status } : {}), + }); +} diff --git a/src/services/blackboard-submission.ts b/src/services/blackboard-submission.ts new file mode 100644 index 0000000..e83ed78 --- /dev/null +++ b/src/services/blackboard-submission.ts @@ -0,0 +1,108 @@ +import { CliError } from "../core/errors.js"; +import { collapseWhitespace, type ServiceAdapter } from "./base.js"; +import { submitBlackboardOriginalForm, type BlackboardFormSubmission, type BlackboardFormTarget } from "./blackboard-assignment-form.js"; +import { getBlackboardAttempt, listBlackboardAttemptFiles, listBlackboardAttempts, type BlackboardAttempt, type BlackboardAttemptFile } from "./blackboard.js"; + +export interface BlackboardSubmissionVerification { + status: "confirmed" | "not_observed" | "unavailable"; + message: string; +} + +/** The caller performs confirmation and live preflight before entering this write flow. */ +export async function applyBlackboardOriginalSubmission( + adapter: ServiceAdapter, + target: BlackboardFormTarget & { columnId: string }, + submission: BlackboardFormSubmission, + previousAttempts: readonly { id: string }[], + options: { comment?: string } = {}, +): Promise<{ + attempt: BlackboardAttempt; + files: BlackboardAttemptFile[]; + verification: BlackboardSubmissionVerification; + recoveredAfterError: boolean; +}> { + let stage: "prepare_form" | "submit_form" | "verify" = "prepare_form"; + let postStarted = false; + let postError: unknown; + try { + await submitBlackboardOriginalForm(adapter, target, submission, { + ...options, + onStage: (nextStage) => { stage = nextStage; if (nextStage === "submit_form") postStarted = true; }, + }); + stage = "verify"; + } catch (error) { + if (!postStarted) { + if (error instanceof CliError && error.code === "BLACKBOARD_FILE_CHANGED") throw error; + throw new CliError("Blackboard submission preparation failed; no submission POST was sent.", "BLACKBOARD_SUBMISSION_PREPARATION_FAILED", 4, { + ...target, stage, upstream: submissionDiagnostic(error), submissionPostSent: false, + }); + } + postError = error; + } + + let candidateAttemptIds: string[] = []; + let attempt: BlackboardAttempt | undefined; + let files: BlackboardAttemptFile[] = []; + let readBackError: unknown; + try { + const previousIds = new Set(previousAttempts.map((entry) => entry.id)); + const current = await listBlackboardAttempts(adapter, target.courseId, target.columnId); + candidateAttemptIds = current.filter((entry) => !previousIds.has(entry.id)).map((entry) => entry.id); + // Neither an old same-named file nor multiple concurrent attempts proves this POST succeeded. + if (candidateAttemptIds.length === 1) { + attempt = await getBlackboardAttempt(adapter, target.courseId, target.columnId, candidateAttemptIds[0]!); + if (attempt.id !== candidateAttemptIds[0]) throw new CliError("Blackboard returned a different attempt during verification.", "BLACKBOARD_ATTEMPT_MISMATCH"); + if (submission.kind === "file") files = await listBlackboardAttemptFiles(adapter, target.courseId, attempt.id); + } + } catch (error) { readBackError = error; } + + const verification = verifySubmission(attempt, files, submission, options.comment); + if (!readBackError && attempt && verification.status === "confirmed") { + return { attempt, files, verification, recoveredAfterError: postError !== undefined }; + } + throw new CliError("Blackboard submission outcome is uncertain. Do not retry automatically.", "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN", 5, { + ...target, stage, candidateAttemptIds, + ...(attempt ? { attemptId: attempt.id, attemptStatus: attempt.status } : {}), + ...(submission.kind === "file" ? { fileName: submission.file.name } : { textFile: submission.textFile.absolutePath }), + observedFiles: files.map((entry) => entry.name), verification, + ...(postError !== undefined ? { upstream: submissionDiagnostic(postError) } : {}), + ...(readBackError !== undefined ? { readBack: submissionDiagnostic(readBackError) } : {}), + warning: "DO_NOT_RETRY_AUTOMATICALLY", + }); +} + +function verifySubmission( + attempt: BlackboardAttempt | undefined, + files: readonly BlackboardAttemptFile[], + submission: BlackboardFormSubmission, + comment: string | undefined, +): BlackboardSubmissionVerification { + if (!attempt) return { status: "unavailable", message: "A single new attempt could not be identified after the submission POST." }; + const submitted = attempt.status === "NeedsGrading" || attempt.status === "Completed"; + const contentMatches = submission.kind === "file" + ? files.some((entry) => entry.name === submission.file.name) + : attempt.studentSubmission === collapseWhitespace(submission.text); + const commentMatches = !comment || attempt.studentComments === collapseWhitespace(comment); + if (submitted && contentMatches && commentMatches) { + return { status: "confirmed", message: "A new submitted attempt and its expected file/text and comment were read back from Blackboard." }; + } + return { status: "not_observed", message: "The new attempt did not match the expected submitted status, file/text, or comment." }; +} + +function submissionDiagnostic(error: unknown): Record { + if (!(error instanceof CliError)) return { code: "REQUEST_FAILED", message: "The Blackboard request did not complete." }; + const details = error.details; + const rawPath = typeof details?.path === "string" ? details.path : details?.url; + let path: string | undefined; + if (typeof rawPath === "string") { + try { + const url = new URL(rawPath, "https://bb.sustech.edu.cn"); + if (url.origin === "https://bb.sustech.edu.cn") path = url.pathname; + } catch { /* Do not forward malformed URLs or credential-bearing query strings. */ } + } + return { + code: error.code, message: error.message, + ...(path ? { path } : {}), + ...(typeof details?.status === "number" ? { status: details.status } : {}), + }; +} diff --git a/src/services/blackboard.ts b/src/services/blackboard.ts index 960efcd..4a6553b 100644 --- a/src/services/blackboard.ts +++ b/src/services/blackboard.ts @@ -19,6 +19,7 @@ import { stringValue, } from "./base.js"; import type { ServiceAdapter, ServiceStatus } from "./base.js"; +import { confirmBlackboardNoAttempts } from "./blackboard-assignment-form.js"; export const BLACKBOARD_BASE = "https://bb.sustech.edu.cn"; @@ -38,8 +39,8 @@ export const BLACKBOARD_STATUS: ServiceStatus = { "Course discussion forums, threads, and replies use the official Learn REST discussion endpoints when the target course exposes them; Blackboard Original courses can reject that API as unsupported.", "Course-message folders, message lists, and participant lists follow the official Learn REST course-message endpoints with explicit paging and server-side folder filters.", "Teacher-provided files use the Learn content-attachment endpoint or same-origin BBML links.", - "Student submission files use the official Learn REST attempt/files flow and remain limited to Classic/Original assignments; supported Blackboard assignment targets can also submit text through the official attempt payload.", - "No Blackboard write path has been live-submitted from this repository yet.", + "Assignment files and text are submitted through the Classic/Original HTTP form with the CAS cookie session; attempt and attachment read-back uses Learn REST. No browser automation is required for submission.", + "Individual Original file resubmission passed live CLI submission and REST read-back on 2026-09-11; text submission and first-submission 404 recovery remain fixture-tested only.", ], endpoints: [ "/learn/api/public/v1/users/me", @@ -63,7 +64,7 @@ export const BLACKBOARD_STATUS: ServiceStatus = { "/learn/api/public/v2/courses/{courseId}/gradebook/columns", "/learn/api/public/v2/courses/{courseId}/gradebook/columns/{columnId}/attempts", "/learn/api/public/v1/courses/{courseId}/gradebook/attempts/{attemptId}/files", - "/learn/api/public/v1/uploads", + "/webapps/assignment/uploadAssignment", "/learn/api/public/v1/uploads/settings", ], }; @@ -2618,7 +2619,15 @@ async function listBlackboardAttemptsForUser( userId, ...(options.status ? { attemptStatuses: options.status } : {}), }); - const page = await fetchBlackboardPage(adapter, url, { absolute: true }); + const page = await fetchBlackboardPage(adapter, url, { absolute: true, onInitialNotFound: async () => { + const assignment = await getBlackboardAssignment(adapter, courseId, columnId); + if (assignment.id !== canonicalIdBody(columnId) || !assignment.contentId + || assignment.availability !== "Yes" || assignment.grading.type !== "Attempts" + || assignment.scoreProviderHandle !== "resource/x-bb-assignment") return false; + const content = await getBlackboardContentItem(adapter, courseId, assignment.contentId); + if (content.handler !== "resource/x-bb-assignment") return false; + return confirmBlackboardNoAttempts(adapter, { courseId, contentId: assignment.contentId }); + } }); return page.results.map((item) => normaliseBlackboardAttempt(item)); } @@ -3062,7 +3071,7 @@ export async function readBlackboardSubmissionTextPayload(path: string): Promise } let text = ""; try { - text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer); } catch (error) { throw new CliError( "The Blackboard submission text file must be valid UTF-8.", @@ -3735,37 +3744,21 @@ export function evaluateBlackboardSubmissionPreflight(input: { const contentHandler = input.content.handler || ""; const scoreProviderHandle = input.assignment.scoreProviderHandle || ""; - const classicFileSubmission = input.content.kind === "assignment" + const classicSubmission = input.content.kind === "assignment" && contentHandler === "resource/x-bb-assignment" && (!scoreProviderHandle || scoreProviderHandle === "resource/x-bb-assignment"); - const ultraTextSubmission = ( - contentHandler === "resource/x-bb-asmt-test-link" - || contentHandler === "resource/x-bb-assessment" - ) && scoreProviderHandle === "resource/x-bb-assessment"; - - if ( - (input.submission.kind === "file" && !classicFileSubmission) - || (input.submission.kind === "text" && !classicFileSubmission && !ultraTextSubmission) - ) { + if (!classicSubmission) { blockers.push({ code: "UNSUPPORTED_CONTENT_TYPE", - message: input.submission.kind === "file" - ? `Content handler ${contentHandler || "unknown"} does not support official REST file attachment; current Blackboard support is limited to Classic/Original assignments.` - : `Content handler ${contentHandler || "unknown"} is not a supported Blackboard assignment submission target.`, + message: `Content handler ${contentHandler || "unknown"} does not support the Classic/Original assignment form submission flow.`, }); } if ( - (input.submission.kind === "file" && scoreProviderHandle && scoreProviderHandle !== "resource/x-bb-assignment") - || (input.submission.kind === "text" - && scoreProviderHandle - && scoreProviderHandle !== "resource/x-bb-assignment" - && scoreProviderHandle !== "resource/x-bb-assessment") + scoreProviderHandle && scoreProviderHandle !== "resource/x-bb-assignment" ) { blockers.push({ code: "UNSUPPORTED_SCORE_PROVIDER", - message: input.submission.kind === "file" - ? `Score provider ${scoreProviderHandle} does not support the official attempt-file endpoint.` - : `Score provider ${scoreProviderHandle} is not a supported Blackboard assignment submission target.`, + message: `Score provider ${scoreProviderHandle} does not support the Classic/Original assignment form submission flow.`, }); } if (input.assignment.availability && input.assignment.availability !== "Yes") { @@ -4252,8 +4245,8 @@ export function normaliseBlackboardAttempt(raw: unknown): BlackboardAttempt { ...(record.score !== undefined ? { score: numberValue(record.score) } : {}), ...(displayGrade.text !== undefined ? { displayGradeText: stringValue(displayGrade.text) } : {}), ...(displayGrade.score !== undefined ? { displayGradeScore: numberValue(displayGrade.score) } : {}), - studentComments: cleanText(record.studentComments), - studentSubmission: cleanText(record.studentSubmission), + studentComments: normaliseBlackboardSubmissionText(record.studentComments), + studentSubmission: normaliseBlackboardSubmissionText(record.studentSubmission), created: stringValue(record.created), modified: stringValue(record.modified), attemptDate: stringValue(record.attemptDate), @@ -4276,6 +4269,10 @@ export function normaliseBlackboardAttemptReceipt(raw: unknown): BlackboardAttem }; } +function normaliseBlackboardSubmissionText(value: unknown): string { + return cleanText(stringValue(value).replace(/]*>|<\/(?:p|div|li|pre|h[1-6])\s*>/giu, "\n")); +} + export function normaliseBlackboardAttemptFile(raw: unknown): BlackboardAttemptFile { const record = recordValue(raw); const rawViewUrl = stringValue(record.viewUrl); @@ -4397,7 +4394,7 @@ async function fetchBlackboardPageChunk( async function fetchBlackboardPage( adapter: ServiceAdapter, pathOrUrl: string, - options: { absolute?: boolean } = {}, + options: { absolute?: boolean; onInitialNotFound?: () => Promise } = {}, ): Promise<{ results: unknown[] }> { let url = options.absolute ? pathOrUrl : buildBlackboardUrl(pathOrUrl); const results: unknown[] = []; @@ -4412,7 +4409,15 @@ async function fetchBlackboardPage( throw new ServiceError("Blackboard pagination returned a repeated next-page URL.", { url }); } visited.add(url); - const pageChunk = await fetchBlackboardPageChunk(adapter, url, { absolute: true }); + let pageChunk: Awaited>; + try { + pageChunk = await fetchBlackboardPageChunk(adapter, url, { absolute: true }); + } catch (error) { + // Never replace a later-page failure or an uncorroborated 404 with an empty history. + if (page === 1 && error instanceof CliError && error.details?.status === 404 + && await options.onInitialNotFound?.().catch(() => false)) return { results: [] }; + throw error; + } results.push(...pageChunk.results); if (!pageChunk.nextPage) return { results }; url = new URL(pageChunk.nextPage, url).toString(); diff --git a/src/sso/cas.ts b/src/sso/cas.ts index 69e7217..211da94 100644 --- a/src/sso/cas.ts +++ b/src/sso/cas.ts @@ -280,6 +280,7 @@ export class CasSession { } this.cookies.add(response, currentUrl); + if (init.redirect === "manual") return response; if (![301, 302, 303, 307, 308].includes(response.status)) return response; const location = response.headers.get("location"); if (!location) return response; diff --git a/src/test/blackboard_form_cli.test.ts b/src/test/blackboard_form_cli.test.ts new file mode 100644 index 0000000..5e84bed --- /dev/null +++ b/src/test/blackboard_form_cli.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +test("compiled bb submit CLI uses CAS and one HTTP form POST, including first-attempt 404 recovery", () => { + const temp = mkdtempSync(join(tmpdir(), "sustech-bb-form-cli-")); + try { + const file = join(temp, "report.pdf"); + const textFile = join(temp, "answer.txt"); + writeFileSync(file, "fixture file bytes"); + // A UTF-8 BOM must survive decoding so the submitted input still matches its byte hash. + writeFileSync(textFile, "\ufeff第一行 \nSecond & line"); + const trace = join(temp, "requests.txt"); + const run = (operation: string, source: string, mode = "normal") => { + writeFileSync(trace, ""); + const args = ["bb", "submit", operation, "--course-id", "8343", "--content-id", "629896", source === file ? "--file" : "--text-file", source, "--comment", "Please ", "--json"]; + if (operation === "apply") args.push("--confirm", "--expected-sha256", createHash("sha256").update(readFileSync(source)).digest("hex")); + const result = spawnSync(process.execPath, ["--import", fileURLToPath(new URL("./fixtures/blackboard-form-fetch.js", import.meta.url)), fileURLToPath(new URL("../cli.js", import.meta.url)), ...args], { + encoding: "utf8", timeout: 15000, + env: { ...process.env, SUSTECH_SID: "fixture-student", SUSTECH_PASSWORD: "fixture-password", SUSTECH_TEST_BB_TRACE: trace, SUSTECH_TEST_BB_MODE: mode }, + }); + assert.doesNotMatch(result.stdout + result.stderr, /fixture-private-nonce|fixture-password/); + return { ...result, envelope: JSON.parse(result.stdout), requests: readFileSync(trace, "utf8").trim().split("\n") }; + }; + const preview = run("preview", file); + assert.equal(preview.status, 0, preview.stdout + preview.stderr); + assert.equal(preview.envelope.data.applyAllowed, true); + assert.equal(preview.envelope.data.attemptsUsed, 0); + assert.equal(preview.requests.some((line) => line.startsWith("POST /webapps/assignment/")), false); + for (const [source, mode] of [[file, "normal"], [textFile, "normal"], [file, "lost-response"]]) { + const result = run("apply", source!, mode!); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(result.envelope.data.verification.status, "confirmed"); + assert.equal(result.envelope.data.attempt.id, "2201"); + assert.equal(result.requests.filter((line) => line === "POST /webapps/assignment/uploadAssignment").length, 1); + assert.equal(result.requests.some((line) => line.startsWith("POST /learn/api/")), false); + } + const redirected = run("apply", file, "redirect"); + assert.equal(redirected.status, 5, redirected.stdout + redirected.stderr); + assert.equal(redirected.envelope.error.code, "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN"); + assert.equal(redirected.envelope.error.details.upstream.status, 307); + assert.equal(redirected.requests.filter((line) => line === "POST /webapps/assignment/uploadAssignment").length, 1); + } finally { rmSync(temp, { recursive: true, force: true }); } +}); diff --git a/src/test/blackboard_form_submission.test.ts b/src/test/blackboard_form_submission.test.ts new file mode 100644 index 0000000..b776f0e --- /dev/null +++ b/src/test/blackboard_form_submission.test.ts @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { CliError } from "../core/errors.js"; +import { confirmBlackboardNoAttempts, submitBlackboardOriginalForm, type BlackboardFormSubmission } from "../services/blackboard-assignment-form.js"; +import { applyBlackboardOriginalSubmission } from "../services/blackboard-submission.js"; +import { listBlackboardAttempts } from "../services/blackboard.js"; +import type { ServiceAdapter } from "../services/base.js"; + +const origin = "https://bb.sustech.edu.cn"; +const target = { courseId: "8343", contentId: "629896", columnId: "991" }; +const attemptsPath = "/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts"; +const formPath = "/webapps/assignment/uploadAssignment"; +const formHtml = `上传作业: Report +
+ + + + + + + + + + +
`; + +function fileInput(): Extract { + const bytes = new TextEncoder().encode("reviewed file bytes"); + return { kind: "file", bytes, file: { path: "/tmp/report.pdf", absolutePath: "/tmp/report.pdf", name: "report.pdf", size: bytes.length, sha256: hash(bytes) } }; +} +function textInput(): Extract { + const text = "第一行 \nSecond & line"; + return { kind: "text", text, textFile: { path: "/tmp/answer.txt", absolutePath: "/tmp/answer.txt", size: Buffer.byteLength(text), sha256: hash(Buffer.from(text)), charCount: [...text].length } }; +} +function hash(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } +function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } }); } +function adapter(route: (url: URL, init: RequestInit) => Response | Promise): ServiceAdapter { + return { name: "offline-fixture", fetch: (url, init = {}) => Promise.resolve(route(new URL(url), init)) }; +} +function acknowledgement(): Response { return json({ destinationUrl: `${formPath}?course_id=_8343_1&content_id=_629896_1&mode=DEFAULT` }); } + +test("Original submission sends one multipart POST with the fresh nonce, exact file and picker fields", async () => { + const input = fileInput(); + const calls: string[] = []; + await submitBlackboardOriginalForm(adapter(async (url, init) => { + calls.push(`${init.method ?? "GET"} ${url.pathname}`); + assert.equal(url.pathname, formPath); + if (!init.method) { + assert.equal(url.searchParams.get("action"), "newAttempt"); + return new Response(formHtml); + } + assert.equal(init.method, "POST"); + assert.equal(init.redirect, "manual"); + const headers = new Headers(init.headers); + assert.equal(headers.get("content-type"), null); // FormData supplies the boundary. + assert.equal(headers.get("x-requested-with"), "XMLHttpRequest"); + const body = init.body as FormData; + assert.equal(body.get("blackboard.platform.security.NonceUtil.nonce"), "fixture-private-nonce"); + assert.equal(body.get("blackboard.platform.security.NonceUtil.nonce.ajax"), "fixture-ajax-nonce"); + assert.equal(body.get("dispatch"), "submit"); + assert.equal(body.get("isAjaxSubmit"), "true"); + assert.equal(body.get("attempt_id"), ""); + assert.equal(body.get("newFile_attachmentType"), "L"); + assert.equal(body.get("newFile_fileId"), "new"); + assert.equal(body.get("newFile_linkTitle"), input.file.name); + const file = body.get("newFile_LocalFile0") as File; + assert.equal(file.name, input.file.name); + assert.equal(hash(new Uint8Array(await file.arrayBuffer())), input.file.sha256); + return acknowledgement(); + }), target, input); + assert.deepEqual(calls, [`GET ${formPath}`, `POST ${formPath}`]); +}); + +test("Original text and comment submissions fill exposed editor fields with escaped text", async () => { + await submitBlackboardOriginalForm(adapter((_, init) => { + if (!init.method) return new Response(formHtml); + const body = init.body as FormData; + assert.equal(body.get("studentSubmission.text"), "第一行 <literal>
Second & line"); + assert.equal(body.get("student_comments"), "Please <review>"); + assert.equal(body.has("newFile_LocalFile0"), false); + return acknowledgement(); + }), target, textInput(), { comment: "Please " }); +}); + +test("changed buffers are rejected before the GET and again before the submission POST", async () => { + const input = fileInput(); + input.bytes[0] = 0; + await assert.rejects(submitBlackboardOriginalForm(adapter(() => { throw Error("must not fetch"); }), target, input), { code: "BLACKBOARD_FILE_CHANGED" }); + const pending = fileInput(); + let calls = 0; + await assert.rejects(submitBlackboardOriginalForm(adapter(() => { + calls += 1; pending.bytes[0] = 0; return new Response(formHtml); + }), target, pending), { code: "BLACKBOARD_FILE_CHANGED" }); + assert.equal(calls, 1); +}); + +test("invalid, foreign, resumed, grouped and draft-bearing forms cannot send a submission POST", async () => { + const invalidForms = [ + "Login", + formHtml.replace('value="_8343_1"', 'value="_9000_1"'), + formHtml.replace(`action="${formPath}`, 'action="https://evil.example/steal'), + formHtml.replace('action=submit"', 'action=submit&course_id=_8343_1&course_id=_9000_1"'), + formHtml.replace('value="fixture-private-nonce"', 'value=""'), + formHtml.replace('name="attempt_id" value=""', 'name="attempt_id" value="_2200_1"'), + formHtml.replace('name="group_id" value=""', 'name="group_id" value="_5_1"'), + formHtml.replace('', ''), + formHtml.replace('', ''), + ]; + for (const html of invalidForms) { + let posts = 0; + await assert.rejects(submitBlackboardOriginalForm(adapter((_, init) => { + if (init.method === "POST") posts += 1; + return new Response(html); + }), target, fileInput()), { code: "BLACKBOARD_SUBMISSION_FORM_ERROR" }); + assert.equal(posts, 0); + } +}); + +test("only a matching first-upload view form corroborates an empty attempt history", async () => { + const read = adapter((url, init) => { + assert.equal(init.method, undefined); + assert.equal(url.searchParams.get("mode"), "view"); + assert.equal(url.searchParams.has("action"), false); + return new Response(formHtml); + }); + assert.equal(await confirmBlackboardNoAttempts(read, target), true); + for (const html of [formHtml.replace("上传作业:", "复查提交历史记录:"), '项目不可用', formHtml + '
', formHtml + 'previous attempt']) { + assert.equal(await confirmBlackboardNoAttempts(adapter(() => new Response(html)), target), false); + } +}); + +function attemptsAdapter(html = formHtml, options: { laterPage?: boolean; hiddenColumn?: boolean; status?: number } = {}): ServiceAdapter { + return adapter((url) => { + if (url.pathname.endsWith("/users/me")) return json({ id: "_1_1" }); + if (url.pathname === attemptsPath) { + if (options.laterPage && !url.searchParams.has("offset")) return json({ results: [{ id: "_2200_1" }], paging: { nextPage: `${attemptsPath}?offset=1` } }); + throw new CliError("Blackboard request failed.", "SERVICE_HTTP_ERROR", 1, { status: options.status ?? 404, path: attemptsPath }); + } + if (url.pathname.endsWith("/columns/_991_1")) { + assert.equal(options.laterPage, undefined); + return json({ id: "_991_1", contentId: "_629896_1", availability: { available: options.hiddenColumn ? "No" : "Yes" }, grading: { type: "Attempts" }, scoreProviderHandle: "resource/x-bb-assignment" }); + } + if (url.pathname.endsWith("/contents/_629896_1")) return json({ id: "_629896_1", contentHandler: { id: "resource/x-bb-assignment" } }); + if (url.pathname === formPath) { + assert.equal(url.searchParams.get("mode"), "view"); + return new Response(html); + } + throw Error(`Unexpected fixture URL ${url.pathname}`); + }); +} + +test("initial attempts 404 becomes empty only after accessible column/content and a blank matching view form", async () => { + assert.deepEqual(await listBlackboardAttempts(attemptsAdapter(), "8343", "991"), []); + for (const fixture of [attemptsAdapter("Login"), attemptsAdapter(formHtml, { hiddenColumn: true }), attemptsAdapter(formHtml, { laterPage: true }), attemptsAdapter(formHtml, { status: 403 })]) { + await assert.rejects(listBlackboardAttempts(fixture, "8343", "991"), { code: "SERVICE_HTTP_ERROR" }); + } +}); + +function writeFlow(options: { postError?: boolean; oldOnly?: boolean; multiple?: boolean; wrongFile?: boolean; inProgress?: boolean; preparationError?: boolean; comment?: string; text?: string } = {}) { + let posts = 0; + const fixture = adapter((url, init) => { + if (url.pathname === formPath) { + if (!init.method) return new Response(formHtml, { status: options.preparationError ? 403 : 200 }); + posts += 1; + if (options.postError) throw new CliError("Blackboard request failed.", "SERVICE_HTTP_ERROR", 1, { status: 404, path: `${formPath}?nonce=must-not-leak`, bodySample: "private nonce and draft" }); + return acknowledgement(); + } + if (url.pathname.endsWith("/users/me")) return json({ id: "_1_1" }); + if (url.pathname === attemptsPath) return json({ results: [{ id: "_2200_1" }, ...(!options.oldOnly ? [{ id: "_2201_1" }] : []), ...(options.multiple ? [{ id: "_2202_1" }] : [])] }); + if (url.pathname === `${attemptsPath}/_2201_1`) return json({ id: "_2201_1", status: options.inProgress ? "InProgress" : "NeedsGrading", studentSubmission: options.text ?? "", studentComments: options.comment ?? "", attemptReceipt: { receiptId: "fixture-receipt" } }); + if (url.pathname.endsWith("/attempts/_2201_1/files")) return json({ results: [{ id: "_3301_1", name: options.wrongFile ? "other.pdf" : "report.pdf" }] }); + throw Error(`Unexpected fixture URL ${url.pathname}`); + }); + return { adapter: fixture, posts: () => posts }; +} + +test("form apply verifies exactly one new attempt and can recover a lost POST response without resending", async () => { + for (const postError of [false, true]) { + const flow = writeFlow({ postError }); + const result = await applyBlackboardOriginalSubmission(flow.adapter, target, fileInput(), [{ id: "2200" }]); + assert.equal(result.attempt.id, "2201"); + assert.equal(result.attempt.attemptReceipt?.receiptId, "fixture-receipt"); + assert.equal(result.verification.status, "confirmed"); + assert.equal(result.recoveredAfterError, postError); + assert.equal(flow.posts(), 1); + } +}); + +test("old same-named submissions, multiple new attempts and mismatched read-back remain uncertain", async () => { + for (const options of [{ oldOnly: true, postError: true }, { multiple: true }, { wrongFile: true }, { inProgress: true }]) { + const flow = writeFlow(options); + await assert.rejects(applyBlackboardOriginalSubmission(flow.adapter, target, fileInput(), [{ id: "2200" }]), (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN"); + assert.equal(error.exitCode, 5); + assert.equal(error.details?.warning, "DO_NOT_RETRY_AUTOMATICALLY"); + if (options.postError) assert.deepEqual(error.details?.upstream, { code: "SERVICE_HTTP_ERROR", message: "Blackboard request failed.", path: formPath, status: 404 }); + assert.doesNotMatch(JSON.stringify(error.details), /must-not-leak|private nonce/); + return true; + }); + assert.equal(flow.posts(), 1); + } +}); + +test("text and comment read-back compare decoded plain text including literal angle brackets", async () => { + const flow = writeFlow({ text: "第一行 <literal>
Second & line", comment: "Please <review>" }); + const result = await applyBlackboardOriginalSubmission(flow.adapter, target, textInput(), [{ id: "2200" }], { comment: "Please " }); + assert.equal(result.verification.status, "confirmed"); + assert.equal(flow.posts(), 1); + const noComment = writeFlow(); + await assert.rejects(applyBlackboardOriginalSubmission(noComment.adapter, target, fileInput(), [{ id: "2200" }], { comment: "required comment" }), { code: "BLACKBOARD_SUBMISSION_OUTCOME_UNKNOWN" }); +}); + +test("form preparation failure reports its HTTP status and sends no submission POST", async () => { + const flow = writeFlow({ preparationError: true }); + await assert.rejects(applyBlackboardOriginalSubmission(flow.adapter, target, fileInput(), []), (error: unknown) => { + assert.ok(error instanceof CliError); + assert.equal(error.code, "BLACKBOARD_SUBMISSION_PREPARATION_FAILED"); + assert.equal(error.details?.submissionPostSent, false); + assert.equal((error.details?.upstream as { status: number }).status, 403); + return true; + }); + assert.equal(flow.posts(), 0); +}); diff --git a/src/test/blackboard_submission.test.ts b/src/test/blackboard_submission.test.ts index 76cb09b..8568acf 100644 --- a/src/test/blackboard_submission.test.ts +++ b/src/test/blackboard_submission.test.ts @@ -459,8 +459,8 @@ test("Blackboard submission preflight surfaces blockers and late-submission warn uploadSettings: { supportsInlineRender: true, maxUploadSizeInBytes: 1024 }, now: new Date("2026-08-26T00:00:00.000Z"), }); - assert.equal(unsupported.ready, true); - assert.deepEqual(unsupported.blockers, []); + assert.equal(unsupported.ready, false); + assert.deepEqual(unsupported.blockers.map((entry) => entry.code), ["UNSUPPORTED_CONTENT_TYPE", "UNSUPPORTED_SCORE_PROVIDER"]); const ultraFile = evaluateBlackboardSubmissionPreflight({ assignment: { diff --git a/src/test/fixtures/blackboard-form-fetch.ts b/src/test/fixtures/blackboard-form-fetch.ts new file mode 100644 index 0000000..fc78294 --- /dev/null +++ b/src/test/fixtures/blackboard-form-fetch.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { appendFileSync } from "node:fs"; + +// Every request is intercepted. This fixture never calls the real network. +const origin = "https://bb.sustech.edu.cn"; +const formPath = "/webapps/assignment/uploadAssignment"; +const attemptsPath = "/learn/api/public/v2/courses/_8343_1/gradebook/columns/_991_1/attempts"; +const formHtml = `Upload Assignment: Fixture +
+ + + +
`; +let submitted = false; +let fileName = ""; +let text = ""; +let comment = ""; +let postCount = 0; +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +} +function assignment() { + return { id: "_991_1", contentId: "_629896_1", name: "Fixture", availability: { available: "Yes" }, grading: { type: "Attempts", attemptsAllowed: 2 }, scoreProviderHandle: "resource/x-bb-assignment" }; +} + +globalThis.fetch = async (input, init = {}) => { + const url = new URL(String(input)); + const method = init.method ?? "GET"; + const trace = process.env.SUSTECH_TEST_BB_TRACE; + if (trace) appendFileSync(trace, `${method} ${url.pathname}\n`); + if (url.hostname === "cas.sustech.edu.cn" && url.pathname === "/cas/login") { + if (method === "GET") return new Response(''); + const response = new Response("Fixture login", { headers: { "set-cookie": "JSESSIONID=fixture-session; Path=/" } }); + Object.defineProperty(response, "url", { value: `${origin}/webapps/bb-sso-BBLEARN/index.jsp` }); + return response; + } + assert.equal(url.origin, origin, "Fixture must not reach another origin"); + if (method === "POST") { + assert.equal(url.pathname, formPath, "Public REST writes must not be used"); + assert.equal(++postCount, 1, "A submission POST must never be replayed"); + const body = init.body as FormData; + assert.equal(body.get("blackboard.platform.security.NonceUtil.nonce"), "fixture-private-nonce"); + assert.equal(body.get("dispatch"), "submit"); + if (process.env.SUSTECH_TEST_BB_MODE === "redirect") return new Response(null, { status: 307, headers: { location: `${origin}${formPath}?action=submit` } }); + const file = body.get("newFile_LocalFile0"); + fileName = file instanceof File ? file.name : ""; + text = String(body.get("studentSubmission.text") ?? ""); + comment = String(body.get("student_comments") ?? ""); + submitted = true; + if (process.env.SUSTECH_TEST_BB_MODE === "lost-response") throw new TypeError("Fixture lost response"); + return json({ destinationUrl: `${formPath}?course_id=_8343_1&content_id=_629896_1&mode=DEFAULT` }); + } + assert.equal(method, "GET"); + if (url.pathname === "/learn/api/public/v1/users/me") return json({ id: "_1_1" }); + if (url.pathname.endsWith("/gradebook/columns")) return json({ results: [assignment()] }); + if (url.pathname.endsWith("/gradebook/columns/_991_1")) return json(assignment()); + if (url.pathname.endsWith("/contents/_629896_1")) return json({ id: "_629896_1", title: "Fixture", contentHandler: { id: "resource/x-bb-assignment" } }); + if (url.pathname === attemptsPath) return submitted ? json({ results: [{ id: "_2201_1", status: "NeedsGrading" }] }) : json({ status: 404, message: "找不到指定对象。" }, 404); + if (url.pathname === `${attemptsPath}/_2201_1`) return json({ id: "_2201_1", status: "NeedsGrading", studentSubmission: text, studentComments: comment, attemptReceipt: { receiptId: "fixture-receipt" } }); + if (url.pathname.endsWith("/attempts/_2201_1/files")) return json({ results: fileName ? [{ id: "_3301_1", name: fileName }] : [] }); + if (url.pathname === "/learn/api/public/v1/uploads/settings") return json({}, 404); + if (url.pathname === formPath) { + assert.ok(["view", null].includes(url.searchParams.get("mode"))); + return new Response(formHtml); + } + throw new Error(`Unexpected fixture request ${method} ${url.pathname}`); +}; From 642c7b4e0fcb3ed33e870dd85119005e8759d512 Mon Sep 17 00:00:00 2001 From: Apryle Wu Date: Sat, 12 Sep 2026 15:42:34 +0800 Subject: [PATCH 2/2] chore: prepare sustech-cli 0.11.1 release --- CHANGELOG.md | 9 +++++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/core/version.ts | 2 +- src/test/blackboard_form_cli.test.ts | 2 +- src/test/cli.test.ts | 4 ++-- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 783f80d..e342b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +## [0.11.1] - 2026-09-12 + ### Fixed - Submit Classic/Original Blackboard assignments with a fresh, target-validated @@ -15,6 +17,13 @@ All notable changes to `sustech-cli` are documented in this file. and later-page failures remain errors. - Preserve submission failure stage and sanitized upstream status/path. Never automatically replay a submission POST, including across HTTP redirects. +- Use a portable file URL for the offline submission fixture on Windows. + +### Validation + +- An authorized Original file resubmission passed live CLI submission and REST + read-back on 2026-09-11. Text submission and first-submission 404 recovery remain + covered by isolated HTTP fixtures. ## [0.11.0] - 2026-09-07 diff --git a/README.md b/README.md index efb6c93..33ee445 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ review. A successful envelope looks like this: "ok": true, "command": "version", "data": { - "version": "0.11.0", + "version": "0.11.1", "runtime": "node v22.19.0" } } diff --git a/package-lock.json b/package-lock.json index 0249818..d6c5d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sustech-cli", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sustech-cli", - "version": "0.11.0", + "version": "0.11.1", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@modelcontextprotocol/server": "^2.0.0", diff --git a/package.json b/package.json index b5e4fb9..a507b7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sustech-cli", - "version": "0.11.0", + "version": "0.11.1", "description": "Human-friendly and agent-ready command-line access to SUSTech services.", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", diff --git a/src/core/version.ts b/src/core/version.ts index a34293c..c21fb4d 100644 --- a/src/core/version.ts +++ b/src/core/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = "0.11.0"; +export const CLI_VERSION = "0.11.1"; export const USER_AGENT = `sustech-cli/${CLI_VERSION} (+https://github.com/wormforce/sustech-cli)`; diff --git a/src/test/blackboard_form_cli.test.ts b/src/test/blackboard_form_cli.test.ts index 5e84bed..990d0f9 100644 --- a/src/test/blackboard_form_cli.test.ts +++ b/src/test/blackboard_form_cli.test.ts @@ -20,7 +20,7 @@ test("compiled bb submit CLI uses CAS and one HTTP form POST, including first-at writeFileSync(trace, ""); const args = ["bb", "submit", operation, "--course-id", "8343", "--content-id", "629896", source === file ? "--file" : "--text-file", source, "--comment", "Please ", "--json"]; if (operation === "apply") args.push("--confirm", "--expected-sha256", createHash("sha256").update(readFileSync(source)).digest("hex")); - const result = spawnSync(process.execPath, ["--import", fileURLToPath(new URL("./fixtures/blackboard-form-fetch.js", import.meta.url)), fileURLToPath(new URL("../cli.js", import.meta.url)), ...args], { + const result = spawnSync(process.execPath, ["--import", new URL("./fixtures/blackboard-form-fetch.js", import.meta.url).href, fileURLToPath(new URL("../cli.js", import.meta.url)), ...args], { encoding: "utf8", timeout: 15000, env: { ...process.env, SUSTECH_SID: "fixture-student", SUSTECH_PASSWORD: "fixture-password", SUSTECH_TEST_BB_TRACE: trace, SUSTECH_TEST_BB_MODE: mode }, }); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 5c6b0a5..68bad25 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -18,7 +18,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint const text = run(["version"]); assert.equal(text.status, 0); assert.match(text.stdout, /:\*##: :#######:/); - assert.match(text.stdout, /sustech-cli 0\.11\.0/); + assert.match(text.stdout, /sustech-cli 0\.11\.1/); assert.doesNotMatch(text.stdout, /\u001b\[/); const json = run(["version", "--json"]); @@ -27,7 +27,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint schemaVersion: "1", ok: true, command: "version", - data: { version: "0.11.0", runtime: `node ${process.version}` }, + data: { version: "0.11.1", runtime: `node ${process.version}` }, }); });