diff --git a/AGENTS.md b/AGENTS.md index 8d3bf21..b2b04e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,10 @@ For mocks and spies use `vi`: `vi.fn()` to create a mock function, `vi.spyOn(obj When removing a string, label, or branch, don't pin its absence with `expect(...).not.toContain("removed string")`. The string is no longer anywhere in the source — nothing realistic could put it back — so the assertion only documents history. Update or delete the positive assertion instead. Negative assertions remain legitimate when the string is still emitted by **another branch of the same render**: e.g., a Confirm test that asserts the "no backup yet" arrow does NOT appear when rendering the "backup already exists" branch is pinning a conditional, not a deleted feature. +**Never poll `lastFrame()` for something an app renders just before it exits.** Ink writes an **empty** frame when it unmounts, so `lastFrame()` returns `""` from that moment on. A component that shows its final message and then calls `exit()` — `SkillPushApp`/`SkillPullApp` refusing without raw mode do it after 20ms — leaves a 20ms window that a 20ms poll has to land in; miss it and the predicate can never become true again, so the test burns its whole budget and reports a hang. That was a real full-suite flake (~1 in 8, never reproducible in isolation, and it survived a 10s budget). Use `tests/helpers/raw-mode.tsx#lastNonEmptyFrame(instance.frames)` instead — it is stable once the app is gone. Reproduce the old failure by awaiting a `setTimeout(400)` before the first poll. + +Note the whole frame *history* (`frames.join`) is the wrong unit for a negative assertion: a prompt legitimately renders for one frame before the effect that replaces it, so `SkillPullApp`'s `not.toContain("❯ ")` would fail against the history while being exactly right against the settled frame. History is fine for "did this ever appear"; settled frame for "what is the user left looking at". + ## Backup behavior `configureClaudeCode` and `configureOpenCode` always replace the live config (`~/.claude/settings.json`, `~/.config/opencode/opencode.json`), but an existing `*.backup` is never overwritten. On the first run a backup is copied from the live config; every subsequent run skips the backup step and leaves the original `*.backup` in place. There is no prompt and no `overwriteBackups` option — preserving the user's pre-CoDev state is the whole point. `restoreTool` then renames `*.backup` back over the live file. @@ -138,7 +142,7 @@ The load-bearing consequence: the provider id **is** CoDev's authorship marker f Every agent CoDev configures has to be *told* the window of the model it's talking to. The gateway serves custom models none of them recognize, and each guesses differently when unconfigured: Codex assumes a 272K fallback, OpenCode assumes context `0` (which disables compaction outright), Continue falls back to a generic default. `src/lib/model-limits.ts` is the single source of truth; the four writers in `configure.ts` translate it into each agent's own knob and hold no window constants of their own. The flat `GATEWAY_CONTEXT_WINDOW` / `GATEWAY_COMPACT_*` constants that used to live in `const.ts` are gone — they encoded the assumption that every gateway model shares one 196608-token window, which stopped being true once the gateway served both a 1M-token and a 200K-token model. -`ModelLimits` is `{ context, trigger, output? }`: the true window, the absolute token count where auto-compaction should fire, and an optional output cap. **`trigger` is explicit rather than a percentage** — the gap between window and trigger is a per-model judgement call, not a constant. `limitsFor(modelId)` resolves **remote → table → `DEFAULT_LIMITS`**, where remote is the gateway's own numbers cached in auth.json and `DEFAULT_LIMITS` (200K/160K) covers anything unrecognized. `MiniMax/MiniMax-M2.7` is deliberately *absent* from the table: the default already describes it, and an entry that merely restates the default is one more thing to keep in sync. +`ModelLimits` is `{ context, trigger, output? }`: the true window, the absolute token count where auto-compaction should fire, and an optional output cap. **`trigger` is explicit rather than a percentage** — the gap between window and trigger is a per-model judgement call, not a constant. `limitsFor(modelId)` resolves **remote → table → `DEFAULT_LIMITS`**, where remote is the gateway's own numbers cached in auth.json and `DEFAULT_LIMITS` (200K/160K) covers anything unrecognized. The 200K sibling of the 1M model is deliberately *absent* from the table: the default already describes it, and an entry that merely restates the default is one more thing to keep in sync. That 1M model's id lives in `model-limits.ts` as `M3_ID`, `atob`-encoded for the same reason `FALLBACK_MODEL` is in `const.ts` — the upstream vendor's name stays out of the shipped bundle, so keep it that way when adding table entries. Each agent takes a different shape, and the differences are the whole reason this module exists: @@ -276,6 +280,8 @@ The way to land there on Windows is **Git Bash**: MSYS2/mintty pipes stdin throu **The `Boolean` in `useCanType` is load-bearing.** Node leaves `isTTY` **undefined** on a pipe rather than setting it false, while `useInput` skips raw mode only on `options.isActive === false` — a strict comparison. Forwarding the raw `undefined` reads as "active" and throws the very error the gate exists to prevent. No unit test can catch it, since `ink-testing-library`'s fake stdin sets a real boolean; it was found by running the built CLI with `< /dev/null`, which is the only way to reproduce it. `tests/lib/tty.test.ts` pins `toBe(false)` rather than falsiness for that reason. **Do the same for any new prompt: gate on `useCanType()`, and smoke-test it with piped stdin, not only under vitest.** +**Testing the no-raw-mode path goes through `tests/helpers/raw-mode.tsx#renderWithoutRawMode`.** `ink-testing-library`'s stdin always reports `isTTY: true` and its `render` takes no options, so the flag has to be flipped on the instance and the tree re-rendered — Ink recomputes `isRawModeSupported` every render. The helper renders an **inert tree first** so the flag is already false when the component under test mounts. Four tests used to mount the real component and flip the flag underneath it, which is a state no real terminal reaches: `useInput`'s effect calls `setRawMode(true)` while raw mode still looks available, and `handleSetRawMode` (ink's `components/App.js`) **throws** whenever it runs with `isRawModeSupported` false — including from the cleanup, whose `setRawMode` identity changes at exactly the moment the flag flips. Mounting in the target state sidesteps the whole window and is what the tests claim to be doing anyway. + ## Diagnostic logging `~/.codev-hub` has two log homes — don't mix them up: diff --git a/README.md b/README.md index 413dea0..8b7944b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,30 @@ cd your-project codevhub init # initialize + index the current project (one time) ``` +## CoDev Office skills (offline bundle) + +`codevhub skill office` installs the CoDev Office skills so your agents can produce and edit real DOCX and XLSX files. Everything ships in one offline bundle — no login and no per-skill downloads: + +```bash +codevhub skill office +``` + +It picks the bundle for your OS, downloads it into `~/.codev-hub/office`, and runs the bundled setup script, which may prompt for `sudo` (macOS/Linux) or UAC (Windows). + +**The bundle is large — roughly 610 MB (Linux), 820 MB (Windows) or 1.4 GB (macOS).** The download folder is kept between runs, so an interrupted transfer resumes where it left off and an already-downloaded bundle is reused. To force a completely fresh download, delete `~/.codev-hub/office`. + +To fetch the bundle now and install later — or to stage it for a machine that has no internet access: + +```bash +codevhub skill office --download-only # download for this OS, don't install +codevhub skill office --platform windows # download another OS's bundle (implies --download-only) +codevhub skill office --dir /media/usb/codev-office # download somewhere else +``` + +Both files land side by side, and the command prints the exact line to run from that folder (`bash codev-office--setup.sh`, or `powershell -ExecutionPolicy Bypass -File .\codev-office-windows-setup.ps1`). A bundle downloaded for another OS is never executed on this machine. + +Two flags are passed straight through to the setup script: `--minimal` (skip the optional extras) and `--skip-verify` (skip the bundle's own SHA-256 check). + ## Switching between self-hosted and proprietary models CoDev points your agents at a self-hosted AI gateway, but you can flip any agent back to its own provider (Anthropic for Claude Code, OpenAI for Codex, and so on) — and back to the gateway again — whenever you like. Because CoDev backs up your original config before it changes anything, the round-trip is safe and repeatable. diff --git a/src/lib/download.ts b/src/lib/download.ts index 096f984..e2c4f5a 100644 --- a/src/lib/download.ts +++ b/src/lib/download.ts @@ -97,7 +97,12 @@ export async function downloadFile(opts: DownloadOptions): Promise { } if (res.body === null) throw new Error(`download had no body: ${url}`); - const contentLength = Number(res.headers.get("content-length")); + // A missing header must fall through to opts.size. `Number(null)` is 0, which + // is finite — reading it straight would make the fallback unreachable and + // report a total of `offset` (0 on a fresh download) for every chunked + // response. + const header = res.headers.get("content-length"); + const contentLength = header === null ? Number.NaN : Number(header); const total = Number.isFinite(contentLength) ? offset + contentLength : (opts.size ?? null); diff --git a/src/lib/help.ts b/src/lib/help.ts index 22f41ae..16e3f11 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -50,7 +50,7 @@ Skill hub: (previews and confirms before upload; --draft-only to stop at DRAFT, --auto-approve for admins, --json for output) skill office Download the CoDev Office offline skills bundle - (minimax-docx, minimax-xlsx) for this OS and run its + (DOCX and XLSX authoring) for this OS and run its setup script (--platform ubuntu|macos|windows to fetch for another OS [implies --download-only], --dir for the download diff --git a/src/lib/model-limits.ts b/src/lib/model-limits.ts index db9264b..16a4074 100644 --- a/src/lib/model-limits.ts +++ b/src/lib/model-limits.ts @@ -41,14 +41,21 @@ export const DEFAULT_COMPACT_PCT = 80; // capacity, too large overruns the model and 400s mid-session. export const DEFAULT_LIMITS: ModelLimits = { context: 200000, trigger: 160000 }; +// The 1M-window model's id, encoded for the same reason FALLBACK_MODEL is in +// const.ts: the upstream vendor's name stays out of the shipped bundle. Decodes +// to the exact id `/v1/models` reports; kept separate from FALLBACK_MODEL (which +// happens to be the same string today) so retargeting the fallback can't +// silently move this window onto another model. +const M3_ID = atob("TWluaU1heC9NaW5pTWF4LU0z"); + // Known gateway models. Keyed by the exact id `/v1/models` reports, which is // what lands in every agent config. // -// MiniMax/MiniMax-M2.7 is deliberately absent: DEFAULT_LIMITS already describes -// it correctly, and an entry that merely restates the default is one more thing -// to keep in sync. +// The 200K sibling of M3_ID is deliberately absent: DEFAULT_LIMITS already +// describes it correctly, and an entry that merely restates the default is one +// more thing to keep in sync. const TABLE: Record = { - "MiniMax/MiniMax-M3": { context: 1000000, trigger: 800000 }, + [M3_ID]: { context: 1000000, trigger: 800000 }, "zai-org/GLM-4.7-cc": { context: 200000, trigger: 160000 }, }; diff --git a/src/lib/office.ts b/src/lib/office.ts index ca29da7..ef016e6 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -8,7 +8,7 @@ import { officeDownloadsDir } from "@/lib/paths.js"; // `codevhub skill office`: fetch the CoDev Office offline bundle (published by // the codev-storage MinIO backend) for this OS and run the bundled setup -// script, which installs the Office skills (minimax-docx, minimax-xlsx, …). +// script, which installs the Office skills (DOCX/XLSX authoring, …). // File names are deterministic per platform — no manifest fetch — and each // bundle carries its own SHA256SUMS.txt that the setup flow can verify. // Non-interactive on purpose — the second half hands the terminal to an @@ -130,7 +130,9 @@ function makeProgressPrinter(name: string): { let lastPercent = -5; return { print(received, total) { - if (total === null) { + // `0` as well as `null`: a zero total is either an unknown length or an + // empty body, and dividing by it renders NaN%/Infinity%. + if (total === null || total === 0) { if (tty) process.stderr.write(`\r${name} ${formatSize(received)}`); return; } @@ -238,8 +240,12 @@ export async function runSkillOffice( // Without per-file checksums an existing file is reused as-is. That's the // point for the GB-scale bundle, but the setup script is tiny and must - // track the published version — always refetch it. + // track the published version — always refetch it. The `.partial` goes too: + // downloadFile resumes from it via Range, so a leftover from an interrupted + // run would splice stale bytes onto a script that has since been republished + // — and with no expected checksum, nothing would catch it. rmSync(join(dir, script), { force: true }); + rmSync(join(dir, `${script}.partial`), { force: true }); for (const name of [script, bundle]) { const progress = makeProgressPrinter(name); diff --git a/tests/DoctorApp.test.tsx b/tests/DoctorApp.test.tsx index 923c379..fe3cd24 100644 --- a/tests/DoctorApp.test.tsx +++ b/tests/DoctorApp.test.tsx @@ -16,6 +16,7 @@ import * as log from "@/lib/log.js"; import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; import * as reexec from "@/lib/reexec.js"; import * as tls from "@/lib/tls.js"; +import { renderWithoutRawMode } from "./helpers/raw-mode.js"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -264,14 +265,10 @@ describe("DoctorApp", () => { ); stubHappyPath(); - // ink-testing-library's stdin always reports isTTY true and takes no - // options; Ink recomputes isRawModeSupported every render, so flipping the - // flag and re-rendering reproduces that terminal. This lands before the - // async environment group resolves, so the network phase reads the new - // value. - const instance = render(); - instance.stdin.isTTY = false; - instance.rerender(); + // A terminal that can't supply keystrokes — see helpers/raw-mode.tsx for + // why the flag has to be false before DoctorApp mounts rather than flipped + // underneath it. + const instance = renderWithoutRawMode(); await waitForFrame(instance.frames, "check(s) failed"); const output = instance.frames.join("\n"); diff --git a/tests/SkillPullApp.test.tsx b/tests/SkillPullApp.test.tsx index a492461..8a2824c 100644 --- a/tests/SkillPullApp.test.tsx +++ b/tests/SkillPullApp.test.tsx @@ -6,6 +6,7 @@ import type { SkillAgent } from "@/lib/skill-dirs.js"; import * as install from "@/lib/skill-install.js"; import * as skillhub from "@/lib/skillhub.js"; import { SkillPullApp } from "@/SkillPullApp.js"; +import { lastNonEmptyFrame, renderWithoutRawMode } from "./helpers/raw-mode.js"; const ESC = String.fromCharCode(27); const DOWN = `${ESC}[B`; @@ -261,29 +262,30 @@ describe("SkillPullApp", () => { }); }); - // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The - // dispatcher normally routes those to the plain runner, so this covers the - // case where Ink's stdin isn't the process's own. Unlike an ungated useInput - // (which throws), an unanswerable picker would just hang forever. - // ink-testing-library's stdin reports isTTY true and takes no options, so the - // flag is flipped and the tree re-rendered — Ink recomputes - // `isRawModeSupported` every render. + // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts and + // helpers/raw-mode.tsx). The dispatcher normally routes those to the plain + // runner, so this covers the case where Ink's stdin isn't the process's own. + // Unlike an ungated useInput (which throws), an unanswerable picker would + // just hang forever. test("without raw mode: explains the missing keyboard instead of prompting", async () => { mockResolve(); const spy = vi.spyOn(install, "installResolvedSkill"); const onDone = vi.fn(); - const node = ( - - ); - - const instance = render(node); - instance.stdin.isTTY = false; - instance.rerender(node); - await waitFor(() => - frameText(instance.lastFrame).includes("cannot supply keystrokes"), + const instance = renderWithoutRawMode( + , ); - const frame = frameText(instance.lastFrame); + + // The last *non-empty* frame, not lastFrame(): the message is shown and the + // app then exits ~20ms later, and Ink writes an empty frame on unmount. A + // poll on lastFrame() has to sample inside that 20ms window or it sees "" + // for the rest of the run — which is how this test flaked under load. The + // whole history would be wrong here: the picker does render for one frame + // before the effect replaces it, so `not.toContain("❯ ")` is an assertion + // about what the user is left looking at. + const settled = () => stripAnsi(lastNonEmptyFrame(instance.frames)); + await waitFor(() => settled().includes("cannot supply keystrokes")); + const frame = settled(); expect(frame).toContain("--here, --global, or --dir"); // Never falls back to a location the user didn't choose. expect(frame).not.toContain("❯ "); diff --git a/tests/SkillPushApp.test.tsx b/tests/SkillPushApp.test.tsx index d61a49a..206a7f9 100644 --- a/tests/SkillPushApp.test.tsx +++ b/tests/SkillPushApp.test.tsx @@ -4,6 +4,7 @@ import * as auth from "@/lib/auth.js"; import * as publish from "@/lib/skill-publish.js"; import * as skillhub from "@/lib/skillhub.js"; import { SkillPushApp } from "@/SkillPushApp.js"; +import { lastNonEmptyFrame, renderWithoutRawMode } from "./helpers/raw-mode.js"; const ESC = String.fromCharCode(27); const stripAnsi = (s: string) => @@ -189,11 +190,9 @@ describe("SkillPushApp login gate", () => { expect(pub).not.toHaveBeenCalled(); }); - // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The - // confirm step is the last thing between the user and an upload, so silence - // must never be read as consent. ink-testing-library's stdin reports isTTY - // true and takes no options, so the flag is flipped and the tree re-rendered - // — Ink recomputes `isRawModeSupported` every render. + // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts and + // helpers/raw-mode.tsx). The confirm step is the last thing between the user + // and an upload, so silence must never be read as consent. test("without raw mode: refuses rather than publishing unconfirmed", async () => { const authSpy = vi .spyOn(skillhub, "hasSkillhubAuth") @@ -202,23 +201,23 @@ describe("SkillPushApp login gate", () => { vi.spyOn(publish, "preparePublishArchive").mockResolvedValue(ARCHIVE); const onDone = vi.fn(); - const node = ( + const instance = renderWithoutRawMode( + />, ); - const instance = render(node); - instance.stdin.isTTY = false; - instance.rerender(node); - await waitFor(() => - frameText(instance.lastFrame).includes("cannot supply keystrokes"), - ); - expect(frameText(instance.lastFrame)).toContain("--json"); + // The last *non-empty* frame, not lastFrame(): the refusal is shown and the + // app then exits ~20ms later, and Ink writes an empty frame on unmount. A + // poll on lastFrame() has to sample inside that 20ms window or it sees "" + // for the rest of the run — which is how this test flaked under load. + const settled = () => stripAnsi(lastNonEmptyFrame(instance.frames)); + await waitFor(() => settled().includes("cannot supply keystrokes")); + expect(settled()).toContain("--json"); expect(authSpy).not.toHaveBeenCalled(); expect(pub).not.toHaveBeenCalled(); await waitFor(() => onDone.mock.calls.length > 0); diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index dde9033..9cafd87 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -6,6 +6,7 @@ import { Login, loginTitle } from "@/components/Login.js"; import { Step } from "@/components/Step.js"; import * as auth from "@/lib/auth.js"; import { clipboard } from "@/lib/clipboard.js"; +import { renderWithoutRawMode } from "../helpers/raw-mode.js"; const ESC = String.fromCharCode(27); const stripAnsi = (s: string) => @@ -496,18 +497,8 @@ describe("Login", () => { // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). Ink // throws from `useInput`'s mount effect there, which used to take down // `codevhub doctor` — the one command that can still explain the problem. - // ink-testing-library's stdin reports isTTY true and takes no options, so the - // flag is flipped and the tree re-rendered: Ink recomputes - // `isRawModeSupported` on every render, so the second pass mounts the - // component exactly as it would in that terminal. + // helpers/raw-mode.tsx mounts the component as that terminal would. describe("without raw mode", () => { - function renderNoRawMode(node: React.ReactElement) { - const instance = render(node); - instance.stdin.isTTY = false; - instance.rerender(node); - return instance; - } - test("still shows the sign-in URL, since the browser completes login", async () => { const url = "https://sso.test/authorize?x=1"; vi.spyOn(auth, "login").mockImplementation((_onLog, onReady) => { @@ -520,7 +511,7 @@ describe("Login", () => { }); const onDone = vi.fn(); - const { lastFrame } = renderNoRawMode( + const { lastFrame } = renderWithoutRawMode( @@ -549,7 +540,7 @@ describe("Login", () => { ); const onDone = vi.fn(); - const { lastFrame } = renderNoRawMode(); + const { lastFrame } = renderWithoutRawMode(); await waitFor(() => (lastFrame() ?? "").includes("Login failed: Connection refused"), diff --git a/tests/helpers/raw-mode.tsx b/tests/helpers/raw-mode.tsx new file mode 100644 index 0000000..e0090d4 --- /dev/null +++ b/tests/helpers/raw-mode.tsx @@ -0,0 +1,46 @@ +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; + +/** + * Render `node` in a terminal that cannot supply keystrokes — Git Bash on + * Windows, where Ink sees a pipe and reports no raw mode (see lib/tty.ts). + * + * ink-testing-library's stdin always reports `isTTY: true` and its `render` + * takes no options, so the flag has to be flipped on the instance afterwards. + * Ink recomputes `isRawModeSupported` on every render, so the rerender is what + * makes it take effect. + * + * The inert first tree is the load-bearing part: the flag must already be false + * when the component under test *mounts*. A real terminal never gains and then + * loses raw mode mid-run, and simulating that raced — `rerender` schedules + * rather than renders, so under load an async effect could resolve first, reach + * a phase that activates `useInput` while raw mode still looked available, and + * leave Ink's `setRawMode` to throw once the flag flipped underneath it. That + * froze the frames and timed the test out. Reproducible on demand by awaiting a + * `setTimeout` between the mount and the flip. + */ +export function renderWithoutRawMode(node: ReactElement) { + const instance = render( ); + instance.stdin.isTTY = false; + instance.rerender(node); + return instance; +} + +/** + * The last frame that actually rendered something. + * + * Prefer this over `lastFrame()` whenever the assertion is about what an app + * left on screen just before it exited. Ink writes an **empty** frame when it + * unmounts, so `lastFrame()` returns `""` from then on. An app that shows its + * final message and exits ~20ms later leaves a 20ms window in which a 20ms poll + * has to land; miss it and the predicate can never become true again, which + * reads as a hang and times the test out under load. + */ +export function lastNonEmptyFrame(frames: string[]): string { + for (let i = frames.length - 1; i >= 0; i--) { + const frame = frames[i]; + if (frame !== undefined && frame.trim() !== "") return frame; + } + return ""; +} diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 1e5b426..29adce1 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -18,6 +18,23 @@ import { installerArgs, runSkillOffice } from "@/lib/office.js"; const sha256 = (data: Buffer | string) => createHash("sha256").update(data).digest("hex"); +// runSkillOffice reads process.platform to pick the bundle and to decide whether +// the installer may run here. Restores the original descriptor rather than +// re-defining a value, so nothing leaks into the files that share this worker. +async function withPlatform( + value: NodeJS.Platform, + fn: () => Promise, +): Promise { + const original = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value, configurable: true }); + try { + return await fn(); + } finally { + if (original) Object.defineProperty(process, "platform", original); + else delete (process as { platform?: unknown }).platform; + } +} + // 1 MiB of deterministic bytes — big enough to flow through the stream // pipeline in multiple chunks. const PAYLOAD = Buffer.alloc(1024 * 1024, "codev-office-test-"); @@ -30,6 +47,9 @@ let baseUrl: string; let rangeLog: (string | undefined)[]; // When true the server ignores Range and always sends the full body with 200. let ignoreRange = false; +// When true the server sends no Content-Length (chunked), as a proxy or a +// streaming origin may. +let omitContentLength = false; // Extra objects (path -> body) the server should serve. let objects: Map; @@ -37,6 +57,7 @@ beforeEach(async () => { tempDir = mkdtempSync(join(tmpdir(), "codev-download-")); rangeLog = []; ignoreRange = false; + omitContentLength = false; objects = new Map([["/payload.bin", PAYLOAD]]); server = createServer((req, res) => { const body = objects.get(req.url ?? ""); @@ -60,6 +81,11 @@ beforeEach(async () => { res.end(rest); return; } + if (omitContentLength) { + res.writeHead(200); + res.end(body); + return; + } res.writeHead(200, { "Content-Length": String(body.length) }); res.end(body); }); @@ -156,6 +182,42 @@ describe("downloadFile", () => { expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); }); + // `Number(res.headers.get("content-length"))` is `Number(null)` === 0 when the + // header is absent, and 0 is finite — reading it straight makes the opts.size + // fallback unreachable and reports a total of `offset` (0 on a fresh + // download), which renders as Infinity%. + test("falls back to the declared size when the server omits content-length", async () => { + omitContentLength = true; + const dest = join(tempDir, "payload.bin"); + const totals: (number | null)[] = []; + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + size: PAYLOAD.length, + endpoint: "test.download", + onProgress: (p) => totals.push(p.total), + }); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + expect(totals.length).toBeGreaterThan(0); + expect(totals.every((t) => t === PAYLOAD.length)).toBe(true); + }); + + test("reports an unknown total when neither side gives a size", async () => { + omitContentLength = true; + const dest = join(tempDir, "payload.bin"); + const totals: (number | null)[] = []; + await downloadFile({ + url: `${baseUrl}/payload.bin`, + dest, + endpoint: "test.download", + onProgress: (p) => totals.push(p.total), + }); + expect(readFileSync(dest).equals(PAYLOAD)).toBe(true); + // null, not 0 — the progress printer branches on it to drop the percentage + // rather than dividing by zero. + expect(totals.every((t) => t === null)).toBe(true); + }); + test("fails with the HTTP status on a missing object", async () => { await expect( downloadFile({ @@ -257,6 +319,69 @@ describe("runSkillOffice", () => { expect(readFileSync(join(dir, bundleName), "utf8")).toBe("stale-bundle"); }); + // The other half of "always refetch the script": deleting the finished file + // alone still leaves downloadFile resuming from a leftover .partial via Range, + // and no checksum is passed that could catch the splice. + test("drops a stale .partial for the script instead of resuming onto it", async () => { + const dir = join(tempDir, "office"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${scriptName}.partial`), "##"); + const code = await runSkillOffice( + ["--download-only", "--dir", dir], + baseUrl, + ); + expect(code).toBe(0); + expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true); + // Both requests went out without a Range header. + expect(rangeLog).toEqual([undefined, undefined]); + }); + + // The PowerShell branch is unreachable from a non-Windows host — a + // cross-platform --platform forces download-only — so the host is stubbed to + // keep the argv shape pinned on the Linux/macOS machines that run this suite. + test("runs the PowerShell installer on a Windows host", async () => { + objects.set("/codev-office-windows.zip", BUNDLE); + objects.set("/codev-office-windows-setup.ps1", SCRIPT); + const dir = join(tempDir, "office"); + let spawned: { command: string; args: string[]; cwd: string } | null = null; + const code = await withPlatform("win32", () => + runSkillOffice( + ["--dir", dir, "--minimal", "--skip-verify"], + baseUrl, + async (command, args, cwd) => { + spawned = { command, args, cwd }; + return 0; + }, + ), + ); + expect(code).toBe(0); + expect(spawned).toEqual({ + command: "powershell.exe", + args: [ + "-ExecutionPolicy", + "Bypass", + "-File", + join(dir, "codev-office-windows-setup.ps1"), + "-Minimal", + "-SkipVerify", + ], + cwd: dir, + }); + }); + + test("exits 1 on an OS with no bundle and downloads nothing", async () => { + const code = await withPlatform("freebsd", () => + runSkillOffice(["--dir", join(tempDir, "office")], baseUrl), + ); + expect(code).toBe(1); + expect(rangeLog).toEqual([]); + }); + + test("exits 1 on an unknown flag and downloads nothing", async () => { + expect(await runSkillOffice(["--wat"], baseUrl)).toBe(1); + expect(rangeLog).toEqual([]); + }); + test("fails cleanly when the bundle is not published", async () => { objects.delete(`/${bundleName}`); const code = await runSkillOffice(