Files
software-mansion__argent/packages/tool-server/test/screen-recording.test.ts
Ignacy Łątka 5d8fc10799 test: stop tests binding machine-wide resources they do not own (#714)
## Why

Three flaky or failing tests were diagnosed separately this week, and
all three turned out to be one defect wearing different clothes: **a
test binding a machine-wide resource it does not own**, so a second
concurrent run — another checkout, a CI matrix cell, a developer's own
app — perturbs it. Those three are fixed in #707, #709 and #711.

Each of those was found by chasing a symptom, and each audit covered
only its own resource type. This is the systematic sweep: all **448 test
files across 13 packages**, plus the 4 `node --test` scripts. (The sweep
itself ran over the 400 files that existed when it started; merging
`main` brought the count to 448, and every category below was re-run
over the full set — 25 files assign `env.HOME` and none omit
`USERPROFILE`, one test file calls `pkill`/`pgrep`
(`vega-cli-timeout.test.ts`, scoped out to #711 below), and no
`listen()` names a fixed port.)

## Findings

| Category | Files checked | Real findings |
|---|---|---|
| Network endpoints (bind / probe / unix socket) | 448 | 4 |
| Filesystem — shared temp namespace (`os.tmpdir()` and literal `/tmp`)
| 448 | 8 |
| Filesystem — real `$HOME` / `~/.argent` | 448 | 4 (28 files) |
| Ambient environment | 448 + all `src` readers | 12 |
| Device / emulator / SDK state | 448 | 2 |
| Process table (`pgrep` / `pkill` / fixed-pattern sweeps) | 448 | **0**
|
| In-process globals leaking across files | 13 packages × 13 modes |
**0** |

Every finding was reproduced with a concrete instrument before being
touched, and the instrument re-run after. Representative:

- **`stop-tools.test.ts`** ran the real `stop-metro` against port 59999,
which `lsof`s and **SIGTERMs whatever it finds**. With a squatter there:
`16 passed` → `1 failed`, and the squatter logged `received SIGTERM #1`.
Now port 1.
- **`bind-failure-telemetry` / `lens-relay-telemetry`** were gated on
the real `~/.argent/flags.json`. Enabling the documented
`tool-server-event-log` flag: `4 passed` → **`4 failed`**, and records
were appended to the developer's real event log.
- **LogFileWriter (12 files)** — its constructor `mkdir -p`s
`os.homedir()/.argent/tmp`. Instrument: run each of the 16 test files
that reference a writer, or a blueprint that builds one, under a scratch
`HOME`/`USERPROFILE`, then test `$HOME/.argent/tmp` for existence. 4
created it; the 12 that actually construct a writer now all call
`scopeTempHome`, and none creates it.
- **`screen-recording.test.ts`** — the device ids are fixtures and
`vi.useFakeTimers()` seeds its clock from the real time, so only the
millisecond a run starts in separates two runs' `.mp4` paths, and each
test deletes the path it derived. Self-concurrent: **2 of 3 rounds
failed** → 0 of 4. Scoping `TMPDIR` removes the window rather than
narrowing it.
- **`DO_NOT_TRACK=1`** → telemetry `282 passed` → **`13 failed`**.
**`npm_config_user_agent=pnpm|yarn|bun`** → installer `535 passed` →
4/5/3 failed. **19 tests required a real `adb`** — PATH shadow + empty
HOME: `30 passed` → **`19 failed`**.
- A fixed `/tmp/argent-nd-<udid8>.sock` **destroyed a concurrent
holder's live socket** (proven by inode) in **3** files that run the
native-devtools factory. Two of them also fail outright:
`native-devtools-app-state` lost **3 of 3** rounds of two concurrent
runs on `expected 'unregistered' to be 'connected'`. All three now
derive the UDID from `process.pid`.
- `flow-visual`, `react-profiler/dump`, `file-inputs`, `http-upload` —
shared `/tmp` paths and prefix scans, each reproduced with a squatter.
In `http-upload` the collision surfaced *as a timeout*, inside
`vi.waitFor`. (`flow-chromium-boot` was fixed on this branch too, but
main's #585 rewrote that file afterwards and the merge kept main's copy,
so it is byte-identical to `origin/main` here and carries no fix.)

## Clean, with the evidence

- **Process table** — an `LD_PRELOAD` exec interposer over a full run
(507 execs) plus all 12 other suites. Excluding
`vega-cli-timeout.test.ts`, which is #711's and does sweep the table
(`pkill -f '^sleep 6910[0-9]$'` at line 134): no
`pgrep`/`pkill`/`killall`/fixed-pattern sweep anywhere, and **no test
signals a pid it did not spawn**.
- **Real device state** — **zero** exec attempts of real `adb` /
`simctl` / `emulator` / `avdmanager` / `vega`; no test asserts a device
list. That list was too narrow: `workspace-reader-integration` spawned
the real `yarn` / `pnpm` / `bun` / `pod` / `eas` / `expo`, which is the
second finding in this row.
- **Cross-file in-process leakage** — structurally impossible: vitest
4.1.9 resolves `pool: "forks"` / `isolate: true`, confirmed by a scratch
experiment (distinct pids, no env leak, module `Map` = 1,1,1). 120
default-mode runs across 12 packages produced 1 failure.
- **`scripts/`** — 4 files, 86 tests: `mkdtemp` throughout, no ports, no
sweeps, writes nothing.

## Verification

Each fix was mutated back and killed by a **named** test under its
instrument — e.g. `× reads a plain absolute path`, `× returns null when
only the extensionless binary exists`, `× pins both names os.homedir()
consults, at the same directory`. Restores verified with `git diff` /
`git status`, never a substring grep.

**Pinned by an instrument only:** the socket fix for
`ios-only-blueprint-gate`, the `~/.argent/tmp` and
`~/.argent/flags.json` fixes, the `/tmp` fixture cleanup, the
`stop-metro` port change (the test asserts `stopped: false` / `pids:
[]`, which hold for either port whenever nothing is listening) and the
`workspace-reader` `PATH` pin (only `node` is asserted; the other seven
probes are not) are not pinned by any suite assertion — reverting them
still passes, and only the external squatter / scratch-`HOME`
instruments catch them. They are pollution fixes. The
`native-devtools-app-state` socket fix is the exception in the other
direction: reverting it turns two concurrent runs red.

Gates, unpiped and read by exit code: `eslint .` 0, `prettier --check .`
0, `tsc --build` 0, `typecheck:tests` 0.
Suite, run as six shards: tool-server **4287 passed, 1 skipped, 0
failed**; argent-cli 462; telemetry 306; argent-tools-client 191;
configuration-core 122; argent-mcp 80; argent-installer 555. All other
packages and `test:scripts` green, identical to baseline.

`lens-tools-platform-gate.test.ts` times out at the 5s default under
full-suite load and passes at `--testTimeout=30000` (24.7s, almost all
of it transform). It predates this branch and is untouched by it.

## Scope

This branch is **test-only** — no `src/` file is touched. Both
production defects the sweep turned up are separated out, so each can be
reviewed as the user-facing fix it is: `ps` without `-ww` orphaning live
tool-servers is #713, and #852 is `ensureToolsServer` respawning on
every call when `ARGENT_HOST` is exported — `buildToolsServerEnv` passes
the inherited value to the child while the state records the literal
`127.0.0.1` the health check then probes. The second surfaced here as
eight stranded `fake-tool-server` processes, and pinning the environment
in this branch removes that signal, which is why it is filed rather than
merely worked around. A third, outside this branch entirely, is #853:
ten `tool-server` test servers omit the host on `listen(0)`, binding
every interface where twenty-two sibling sites pass `127.0.0.1`. It is
hardening, not a live exposure — `createHttpApp`'s Host allow-list
answers a LAN-addressed request with 403, and only a raw client forging
`Host: 127.0.0.1` reaches the stub registry behind it, inside the ~100
ms one `it()` holds the socket. It is also not a collision risk:
ephemeral ports are kernel-assigned and never reissued while in use,
which is why the fixed-port audit above is unaffected. All ten predate
this branch.

Left untouched on purpose: `boot-electron-spawn-error.test.ts`,
`chromium-discovery.test.ts` and `vega-cli-timeout.test.ts` — owned by
the still-open #709 and #711, and all three still carrying their defects
on this base. (#707 has since merged into this base, so
`boot-device-hotboot.test.ts` and `vitest.config.ts` carry its fix
rather than a defect; `test/setup/clear-argent-env.ts` is its work, and
this branch relies on it.)

**Not fixed, deliberately** — an adjacent class (test-*order*
dependence, not machine-global resources), reproducible if wanted:
`link-config.test.ts` (deterministic: `-t "returns null when required
field"` → 4 failed), `network/network-integration.test.ts` and
`android-perfetto/dispatch.test.ts` (fail under `--sequence.shuffle`
seeds).

**Also observed, not addressed:** one green tool-server run leaves
**56** entries behind in a scratch `TMPDIR` — **30** in the
`argent-screenshot-diff*` family, 4 `argent-events-*`, 3
`argent-flow-crop-*`, 3 `argent-tar-upload-*`, 2 `argent-flow-diff-*`, 2
`argent-file-input-*`, and 12 assorted singletons (`ast-index-*`,
`component-source-*`, `argent-profiler-cwd`, `argent-chromium-media`,
…). Measured per run against a scoped temp dir, so it ranks the
contributors rather than counting one machine's accumulated history —
screenshot-diff, not flow-crop, is the dominant one. A separate leak.
That instrument is blind to a *literal* path, which is how it missed the
two `/tmp/native-profiler-*-report.md` writers the fourth pass found.
Re-run across all 13 packages, `argent-mcp` is the only one outside
`tool-server` that leaves anything — two entries per run, filed as #854.


## Second pass

A review of this branch re-ran every category above with its own
instrument rather than reading the diff, and found the sweep had pinned
four resources in one file while leaving a sibling that does the same
thing live. Fixed in `4289e3d8f` and `8164955b4`; the table and bullets
above are the corrected totals.

- **`tool-server-event-log`** was mocked off in 2 of the 4 files that
call the real `start()`. With the flag on, `startup-telemetry` and
`tool-fail-invalid-params-emission` failed 3 tests in
`attachRegistryEventLogger` and truncated the real
`~/.argent/tool-server-events.jsonl` to 0 bytes first.
`ARGENT_EVENT_LOG` cannot redirect that:
`test/setup/clear-argent-env.ts` strips every `ARGENT_*` var before the
module graph loads.
- **`scopeTempHome`** reached 8 files; 4 more built a real
`LogFileWriter` through the JS-runtime-debugger blueprint.
- **`os.homedir()` on Windows** reads `USERPROFILE`, not `HOME`.
`android-binary-windows` and `adb-resolve-avd-path` pinned only `HOME`,
and both run on a real windows-latest host. Proven by running the
resolver under a `node:os` mock that reproduces the Windows lookup: with
`HOME` alone the stock Studio SDK under the profile resolves; with both
pinned it misses. `update-checker` had the same gap.
- **`getConsentState`** honours a falsy `ARGENT_TELEMETRY` at the same
precedence as `DO_NOT_TRACK`, ahead of the config override the suite
writes. Only `DO_NOT_TRACK` was pinned: `ARGENT_TELEMETRY=0` gave `17
failed | 6 passed`.
- **`workspace-reader-integration`** let the version probe spawn the
real package managers off `PATH`; their shims self-install, so the file
pulled **~44 MB** into `~/.cache/node/corepack` and
`~/Library/Caches/eas-cli`. `PATH` is now a directory holding only
`node`, the one version the test asserts — 44 MB → 4 KB.
- **`argent-mcp`** snapshotted `ARGENT_AUTO_SCREENSHOT_DELAY_MS` without
clearing it, and that package has no `ARGENT_*`-stripping setup file, so
3 tests asserted the developer's exported value.

Smaller items in the same commits: the `/tmp` socket fixtures now remove
the directories they create per run; `http-upload`'s pre-scan snapshot
is gone, since scoping `TMPDIR` to a fresh directory made it permanently
empty and its filter inert, as its two siblings already did; and two
`detectProjectPackageManager` assertions that enumerated the entire
return union are pinned to a single value.


The confirmation pass also caught a defect the first round of fixes
introduced: pinning `HOME`/`USERPROFILE` to the fixture root in
`android-binary-windows` let `<home>/Android/Sdk` shadow the
`%LOCALAPPDATA%` root the two positive tests are named after, so
deleting `roots.push(join(localAppData, …))` from the resolver left all
three green. Home has its own directory in `6aee3ee7a`, and that
mutation now fails both tests.


A third pass, running the nearest-twin move repo-wide, found the
`os.homedir()`-on-Windows gap in one more package: eight
`argent-tools-client` test files redirect only `HOME`, and four of them
recursively delete `STATE_DIR` — two before each test, two after — which
on Windows is the developer's real `%USERPROFILE%\.argent`. Fixed in
`139447d4a`, together with the same gap in `avd-snapshot`. Nothing runs
that package on Windows CI today, so no current run fires it.

## Fourth pass

A fourth pass audited the three fix commits and re-ran the sweep with
two instruments the earlier ones did not have. Both holes were in the
*measurement*, not the diff — which is why three passes missed what they
found. Fixed in `a67c43f8d`.

- **`os.tmpdir()` does not read the same variable on every platform.**
POSIX takes `TMPDIR`, `TMP`, `TEMP` in that order; Windows takes `TEMP`,
then `TMP`, and never looks at `TMPDIR`. The five pins this branch added
set `TMPDIR` alone, so on Windows production still materialized into the
machine-wide `%TEMP%` while the test scanned a scratch directory nothing
ever wrote to — and `file-inputs`, `http-upload` and `flow-visual` each
end in a leak assertion over that listing, which then passes whatever
the code does. Against `origin/main`, which diffed a before/after
listing of the real tmpdir, that is a regression rather than a wash.
`redirectTmpdir` (`test/helpers/tmpdir-env.ts`) sets all three and
returns the restore; the **7** files that scope a tmpdir use it,
including the two `android-perfetto` ones that predate this branch.
`test/tmpdir-env.test.ts` pins both lookup chains — reverting the helper
to `TMPDIR` alone fails two of its three tests. All three leak
assertions were re-confirmed live on POSIX by mutating the production
cleanup each one covers.
- **A hardcoded absolute path is invisible to a `TMPDIR`-scoped
instrument.** `session-correctness` and
`ios-instruments/analyze-freshness` passed a literal
`/tmp/native-profiler-{x,ios}.trace` as the session's trace file, and
the renderer writes its report beside it (`deriveReportPath` =
`dirname(traceFile)/<base>-report.md`). Every run left
`native-profiler-x-report.md` and `native-profiler-ios-report.md` in the
shared `/tmp`; nothing removed them, and two concurrent runs write the
same names. Reproduced with `TMPDIR` pointed at a scratch dir — the
files still landed in `/tmp`, which is exactly why the `os.tmpdir()` row
and the leftover census below both read clean here. Both tests now own
the directory.
- **A prefix-scanned env var is invisible to a grep for variable
names.** `secretSources` enumerates `process.env` by the
`ARGENT_SECRET_` prefix and ranks it *above* every file the test writes,
so `secrets-command.test.ts` — which already pins `HOME`, `USERPROFILE`
and cwd — still read the developer's shell. `ARGENT_SECRET_FOO=bar`,
argent's own documented way to supply a secret: `6 passed` → **`3 failed
| 3 passed`**, deterministic. `argent-cli` has no `ARGENT_*`-stripping
setup file, so the prefix is cleared per test. Its sibling
`configuration-core/tests/secrets.test.ts` was already immune — it
threads an explicit `env: NO_ENV` into every call.
- **`android-binary` and `vega-cli-fallback`** were the last two files
redirecting `HOME` without `USERPROFILE` — the former the direct twin of
`android-binary-windows`, in the same directory, driving the same
resolver. Under a `node:os` mock reproducing the Windows lookup with an
SDK planted in the profile, it loses **5 of 11** tests. No test file in
the repo redirects `HOME` alone any more. (`6fbb66ce1` later skipped
both of this file's describes on win32 — its fixtures are extensionless
and the win32 resolver only takes `.exe` — so its own `USERPROFILE` pin
is now symmetry with the sibling files that do run there, not a live
guard.)

Two smaller items in the same commit. `native-devtools-socket-bind`'s
ENOENT fixture still rooted at `os.tmpdir()` while the comment three
screens above explains why the others do not: with a 142-character
ambient `$TMPDIR` that path exceeds `sun_path` and `listen` rejects it
for *that* reason, which the assertions cannot tell from the
missing-directory one they were written for. And
`<home>\AppData\Local\Android\Sdk` — the resolver's fallback for a
GUI-spawned server that never inherited `%LOCALAPPDATA%` — could be
deleted with the whole suite green; it now has a test, and deleting
either win32 root fails exactly the tests named for it.

## Fifth pass

A fifth pass took the platform-branch shape the fourth pass exposed and
ran it as its own move: *for every function that branches on
`process.platform`, does the test's guard cover every arm, or only the
one the author's machine takes?* Fixed in `e08b466b6`.

- **`init-stale-config.test.ts` deletes the developer's real VS Code MCP
config on Windows.** The file mocks `os.homedir()` and says it does so
"so hidden-scope probes (`~/.claude.json`, VS Code user profile) never
touch the real home directory". `vscodeUserDirs()` branches `darwin →
homedir()/Library/Application Support`, `win32 → %APPDATA%`, `else →
homedir()/.config` — the win32 arm never calls `homedir()`, so the mock
is inert there, and the test's own `userMcpJsonPath()` mirrors the same
branch. Both user-profile tests then write a one-entry fixture over
`%APPDATA%\Code\User\mcp.json` with a whole-file `writeFileSync`, and
assert that `remove()` deleted it. Reproduced by forcing
`process.platform` to `win32` with a two-server `mcp.json` planted at a
probe `%APPDATA%`: **`3 passed`, and the file and its directory were
gone.** With `APPDATA` pinned, the whole file passes under the same
instrument — 31 tests — and the planted config is byte-identical.
- **`argent-tools-client` hands its ambient `ARGENT_*` to a real spawned
child.** The launcher spawns an actual tool-server, and
`buildToolsServerEnv` overrides only the keys it is given — everything
else rides in on `...baseEnv`. `ARGENT_HOST` is the one that bites: the
fixture binds the host it names while the launcher records and
health-checks `127.0.0.1`, so reuse misses and a second server is
spawned. With six overrides exported: **`10 failed | 181 passed`, and
eight stray `fake-tool-server` processes still holding TCP listen
sockets after the run.** With the prefix stripped: `191 passed`, none
surviving. The package now carries the same setup file `tool-server`
has, for the same reason — and it is the second of the two packages
whose missing `ARGENT_*` strip this review found.

## Later passes

A sixth pass audited the fix commits themselves and bounded the SDK
probe (`6fbb66ce1`); a seventh ran the moves against the diff one more
time and turned up the two below, plus the `listen(0)` defect filed as
#853.

- **The `PATH` pin made `workspace-reader-integration` POSIX-only
without the guard its siblings got.** The shim it puts on `PATH` is an
extensionless `node`, and `symlink()` to a file needs an elevated
process or Developer Mode on Windows, so `beforeAll` throws `EPERM`
there before any assertion runs; a copy in place of the symlink would
not rescue it either, since a bare command name resolves through
`PATHEXT`. This sweep already skips `android-binary` and
`native-devtools-socket-bind` on win32 for exactly this class, and the
Windows job runs a curated list of seven `tool-server` files that
excludes this one, so nothing was catching it. Fixed in `095d75bd8` —
verified POSIX `2 passed`, and under a forced win32 platform `2 skipped`
with the top-level `beforeAll` never entered.
- **A rationale that rested on a case the file excludes.**
`android-binary`'s header justified pinning `USERPROFILE` by what
`os.homedir()` reads on Windows, two sentences before noting that both
describes skip on win32. Fixed in `f37b3605e`; the variable stays in the
snapshot list, the justification no longer claims a platform this file
never reaches.

A seventh pass also re-ran the sweep against a hostile machine: every
feature flag on in a scratch `~/.argent/flags.json`, no `adb` on `PATH`,
and a `TMPDIR` long enough to overrun the 104-byte `sun_path` limit. It
found one resource this branch had not closed.

- **A red `launcher-version-gate` run strands a real tool-server.**
`ensureToolsServer` spawns a fixture server, but its pid only reached
the reaper after three or four assertions on the state it returned; any
of those throwing left the pid unrecorded, and the same `afterEach` then
deleted `STATE_DIR`, the only other record of what to kill. **Eight such
processes were alive on the development machine**, spread from 14:33 to
18:23, every one from an `argent-version-gate-test-*` fixture and none
from any other family — each still holding a TCP listen socket with its
fixture directory long gone. Forcing one assertion to fail took the
`tool-server.cjs start` count from 10 to 11. The nearest twin,
`launcher-duplicate-spawn`, guards this twice: it records the pid
straight after the null check and sets `FAKE_TTL_MS` so an escaped
server self-exits. Fixed in `26c2c60ec` with both, the TTL also covering
the window between the spawn and the state read where no pid exists yet
— the same forced failure now leaves **zero** strays.

An eighth pass audited the seventh's own fixes, and found the
version-gate fix had been applied to one file when the defect was a
property of four.

- **Three more files strand a real tool-server on a red run.**
`launcher-spawn` and `launcher-exit` set `FAKE_TTL_MS` nowhere;
`launcher-duplicate-spawn` — the file `26c2c60ec` cites as the model —
armed it inside one test's `try/finally`, so eight of its nine spawns
ran without it and the `finally` disarmed it for the remainder of that
test. Reproduced per file by forcing a failure before the pid is
recorded: a detached node process survives, reparented to init, holding
a listening socket, with its `TEST_HOME` removed and its state record
already cleared. Fixed in `3bb69ded6` by moving the net to a file-wide
`beforeEach` in all three. Verified end to end — the child carries
`FAKE_TTL_MS=60000`, and a stranded server spawned at 21:28:36 exited by
itself at 21:29:36 with nothing signalling it; three consecutive green
runs stay at 191 passed.
- **A 29th file writes the real `~/.argent`.**
`boot-electron-spawn-error`'s success-path test completes a boot, and a
completed boot calls `trackChromiumPort`, persisting to
`os.homedir()/.argent/chromium-cdp-ports.json`. The real file on the
development machine had accumulated **106 dead ephemeral ports**. Its
two siblings in the same directory already mock `chromium-discovery` for
exactly this reason; this was the third, with no mock. Fixed in
`3306b6f40`.
- **`scopeTempHome` was itself unpinned.** Deleting its `USERPROFILE`
line — the Windows half, the same property the sibling helper's test
pins — left all twelve consumers green at 48 passed. `3306b6f40` adds
the missing test; it kills that mutation plus dropping the `HOME` pin
and replacing the per-test `mkdtemp` with one shared directory.

Two findings from that pass were refuted on verification and are
recorded rather than acted on: the tool-server tmpdir leftovers
(mechanism real, but they are the separate leak this body already scopes
out) and a claim that `26c2c60ec`'s message overstated its twin.

Four comments were also corrected against their code: `deriveReportPath`
joins `<base>-report.md` *inside* the trace's directory rather than
appending to the directory's name, which is what makes the
`rm(traceDir)` added in `a67c43f8d` cover it; two `android-perfetto`
comments still named `TMPDIR` as the mechanism after the switch to
`redirectTmpdir`; and `android-binary`'s header claimed the home pin
lets its two positive tests resolve "on either platform", though its
fixtures are extensionless and the win32 resolver only accepts `.exe`.
2026-08-19 23:06:48 +02:00

1470 lines
55 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import { getFailureSignal, FAILURE_CODES, type DeviceInfo } from "@argent/registry";
import type { ChildProcess } from "child_process";
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("child_process")>();
return { ...actual, spawn: vi.fn() };
});
vi.mock("../src/tools/screen-recording/mjpeg-stream", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/tools/screen-recording/mjpeg-stream")>();
// readJpegDimensions stays real (it is exercised directly further down).
return { ...actual, openMjpegStream: vi.fn() };
});
vi.mock("../src/tools/screen-recording/watermark", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/tools/screen-recording/watermark")>();
return {
...actual,
resolveFfmpeg: vi.fn(async () => "/fake/ffmpeg"),
writeLogoTemp: vi.fn(async () => "/tmp/fake-logo.png"),
};
});
import { spawn } from "child_process";
import {
screenRecordingSessionBlueprint,
type ScreenRecordingSessionApi,
} from "../src/blueprints/screen-recording-session";
import {
startCapture,
stopCapture,
framesDue,
ffmpegArgs,
type PointerControl,
} from "../src/tools/screen-recording/capture";
import { setPointerTrail, setPointerVisible } from "../src/utils/simulator-client";
import { makePointerControl } from "../src/tools/screen-recording/screen-recording-start";
import type { SimulatorServerApi } from "../src/blueprints/simulator-server";
import { openMjpegStream, readJpegDimensions } from "../src/tools/screen-recording/mjpeg-stream";
import { resolveFfmpeg, writeLogoTemp } from "../src/tools/screen-recording/watermark";
import {
__resetActiveScreenRecordingsForTesting,
getActiveScreenRecordings,
} from "../src/utils/screen-recording-reminder";
import { __resetReapedSessionsForTesting } from "../src/utils/reaped-sessions";
import { redirectTmpdir } from "./helpers/tmpdir-env";
const mockSpawn = vi.mocked(spawn);
const mockOpenStream = vi.mocked(openMjpegStream);
const mockResolveFfmpeg = vi.mocked(resolveFfmpeg);
const IOS_UDID = "6DBF83B4-0000-0000-0000-000000000000";
const ANDROID_SERIAL = "emulator-5554";
const STREAM_URL = "http://127.0.0.1:54321/stream.mjpeg";
/** Grace the start holds after spawning ffmpeg before declaring it live. */
const READY_GRACE_MS = 800;
/** Minimal JPEG: SOI + SOF0 declaring the frame size + EOI. */
function fakeJpeg(width = 1320, height = 2868): Buffer {
const sof = Buffer.alloc(19);
sof.writeUInt16BE(0xffd8, 0); // SOI
sof.writeUInt16BE(0xffc0, 2); // SOF0
sof.writeUInt16BE(17, 4); // segment length
sof.writeUInt8(8, 6); // precision
sof.writeUInt16BE(height, 7);
sof.writeUInt16BE(width, 9);
sof.writeUInt16BE(0xffd9, 17); // EOI
return sof;
}
class FakeStdin extends EventEmitter {
writable = true;
writableLength = 0;
ended = false;
writes: Buffer[] = [];
write = vi.fn((chunk: Buffer) => {
this.writes.push(chunk);
return true;
});
end = vi.fn(() => {
this.ended = true;
this.writable = false;
});
}
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
stdin = new FakeStdin();
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
kill = vi.fn((_signal?: NodeJS.Signals) => true);
/** Simulate the process ending: stamps exitCode/signalCode, emits 'exit'. */
exit(code: number | null, signal: NodeJS.Signals | null = null): void {
this.exitCode = code;
this.signalCode = signal;
this.emit("exit", code, signal);
}
/** ffmpeg's real behaviour: stdin EOF finalizes the file and it exits. */
exitOnStdinEnd(code = 0): void {
this.stdin.end.mockImplementation(() => {
this.stdin.ended = true;
this.stdin.writable = false;
queueMicrotask(() => this.exit(code));
});
}
}
interface FakeStream {
latest: Buffer | null;
frameCount: number;
error: Error | null;
waitForFirstFrame: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
function fakeStream(overrides: Partial<FakeStream> = {}): FakeStream {
const frame = fakeJpeg();
const stream: FakeStream = {
latest: frame,
frameCount: 1,
error: null,
waitForFirstFrame: vi.fn(async () => frame),
close: vi.fn(),
...overrides,
};
mockOpenStream.mockResolvedValueOnce(stream as never);
return stream;
}
function fakeChild(): FakeChild {
const child = new FakeChild();
mockSpawn.mockReturnValueOnce(child as unknown as ChildProcess);
return child;
}
/** A PointerControl whose enable/disable calls are observable. */
function fakePointer(enableResult = true) {
const enable = vi.fn(async () => enableResult);
const disable = vi.fn(async () => {});
return { enable, disable } satisfies PointerControl;
}
async function makeSession(device: DeviceInfo): Promise<ScreenRecordingSessionApi> {
// The payload argument is unused by this factory; options carry the device.
const instance = await screenRecordingSessionBlueprint.factory({}, device, {
device,
} as never);
return instance.api;
}
/**
* Drive a start through its fail-fast grace. With fake timers the grace timer
* has to be advanced by hand; `flush` lets the promise chain settle first.
*/
async function startAndSettle(
api: ScreenRecordingSessionApi,
params: {
timeLimitSeconds?: number;
watermark?: boolean;
trimStatic?: boolean;
pointer?: PointerControl;
} = {}
): Promise<Awaited<ReturnType<typeof startCapture>>> {
const promise = startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: params.timeLimitSeconds ?? 180,
watermark: params.watermark ?? false,
// Default off in the helper so pacing/duration tests exercise the plain
// wall-clock path; trim-specific tests opt in explicitly.
trimStatic: params.trimStatic ?? false,
pointer: params.pointer,
});
// Mark it handled: a start that rejects while the timers below are being
// advanced would otherwise surface as an unhandled rejection, even though
// the caller awaits the same promise for its error.
promise.catch(() => {});
await vi.advanceTimersByTimeAsync(READY_GRACE_MS);
return promise;
}
const iosDevice: DeviceInfo = { id: IOS_UDID, platform: "ios", kind: "simulator" } as DeviceInfo;
const androidDevice: DeviceInfo = {
id: ANDROID_SERIAL,
platform: "android",
kind: "emulator",
} as DeviceInfo;
// startCapture names its output join(os.tmpdir(),
// `argent-screen-recording-${deviceId}-${Date.now()}.mp4`). The device ids are
// fixtures, and vi.useFakeTimers() seeds its clock from the real time rather
// than a constant, so the millisecond two runs start in is the only thing
// separating their paths — and each test ends by deleting the path it derived.
// Scoping the tmpdir per test removes that window instead of leaving it narrow.
let restoreTmpdir: () => void;
let scratch: string;
beforeEach(async () => {
scratch = await fs.mkdtemp(path.join(os.tmpdir(), "argent-screen-recording-test-"));
restoreTmpdir = redirectTmpdir(scratch);
__resetActiveScreenRecordingsForTesting();
__resetReapedSessionsForTesting();
mockSpawn.mockReset();
mockOpenStream.mockReset();
mockResolveFfmpeg.mockReset();
mockResolveFfmpeg.mockResolvedValue("/fake/ffmpeg");
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
restoreTmpdir();
await fs.rm(scratch, { recursive: true, force: true });
});
describe("screen-recording session blueprint", () => {
it("rejects a factory call without a resolved device", async () => {
try {
await screenRecordingSessionBlueprint.factory({}, iosDevice, {} as never);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_FACTORY_OPTIONS_MISSING
);
}
});
it("dispose reaps a child whose start is still mid-readiness", async () => {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
const child = new FakeChild();
instance.api.pendingChild = child as unknown as ChildProcess;
await instance.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
expect(instance.api.pendingChild).toBeNull();
});
it("rejects platforms that cannot record", async () => {
try {
await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: { id: "chromium-1", platform: "chromium", kind: "app" },
} as never);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_WRONG_PLATFORM);
}
});
it("dispose stops the pump, closes the stream and finalizes via stdin EOF", async () => {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
const api = instance.api;
const stream = fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
await instance.dispose();
expect(child.stdin.ended).toBe(true);
expect(stream.close).toHaveBeenCalled();
expect(api.pumpTimer).toBeNull();
expect(getActiveScreenRecordings()).toHaveLength(0);
});
it("removes the watermark temp file it would otherwise abandon", async () => {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
const api = instance.api;
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
// Use a real temp file so the removal is observable.
const logo = path.join(os.tmpdir(), `argent-test-logo-${process.pid}.png`);
await fs.writeFile(logo, Buffer.alloc(8, 1));
vi.mocked(writeLogoTemp).mockResolvedValueOnce(logo);
await startAndSettle(api, { watermark: true });
expect(api.logoFile).toBe(logo);
await instance.dispose();
await expect(fs.access(logo)).rejects.toThrow();
expect(api.logoFile).toBeNull();
});
describe("a capture reaped by stop-all-simulator-servers", () => {
// The teardown sequence from the review: start a recording, let
// `stop-all-simulator-servers` reap the device (which is what disposes this
// service), then call `screen-recording-stop`. `Registry._teardown` nulls
// the node's instance, so that stop resolves a BRAND NEW session — modelled
// here by building a second one for the same device.
async function reapDuringCapture(): Promise<{
output: string;
fresh: ScreenRecordingSessionApi;
}> {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
fakeStream();
fakeChild().exitOnStdinEnd();
await startAndSettle(instance.api);
const output = instance.api.outputFile!;
await instance.dispose();
return { output, fresh: await makeSession(iosDevice) };
}
it("tells the owner the recording was torn down, and where the video landed", async () => {
const { output, fresh } = await reapDuringCapture();
const err = await stopCapture(fresh).catch((e: unknown) => e);
const message = (err as Error).message;
// The bug: this used to be "No active screen recording … Call
// `screen-recording-start` first." while a finalized video sat on disk.
expect(message).not.toMatch(/Call `screen-recording-start` first/);
expect(message).toContain("torn down");
expect(message).toContain("stop-all-simulator-servers");
// Nothing else in the process still knows this path exists.
expect(message).toContain(output);
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN
);
});
it("tells the owner when the teardown hit a CAPPED capture awaiting retrieval", async () => {
// `hadUnretrievedCapture` has three arms and only `recordingActive` was
// covered. This is the likeliest real sequence of the three: the time
// limit fires, the video is finalized and waiting to be handed over, and
// the teardown lands in that window. The caller is owed a video just as
// much as in the mid-capture case.
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
fakeStream();
fakeChild().exitOnStdinEnd();
await startAndSettle(instance.api, { timeLimitSeconds: 5 });
await vi.advanceTimersByTimeAsync(5_000);
expect(instance.api.recordingActive).toBe(false);
expect(instance.api.pendingRetrieval).toBe(true);
const output = instance.api.outputFile!;
await instance.dispose();
const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e);
const message = (err as Error).message;
expect(message).not.toMatch(/Call `screen-recording-start` first/);
expect(message).toContain("torn down");
expect(message).toContain(output);
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN
);
});
it("tells the owner when the teardown hit a start still mid-readiness", async () => {
// The third arm. `startPending` is set synchronously before start's first
// await, so a teardown here destroys a capture whose child may already be
// spawned — reported as a teardown, not as "you never started one".
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
fakeStream();
fakeChild();
const pending = startCapture(instance.api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 180,
watermark: false,
trimStatic: false,
});
pending.catch(() => {});
expect(instance.api.startPending).toBe(true);
await instance.dispose();
await pending.catch(() => {});
const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e);
expect((err as Error).message).not.toMatch(/Call `screen-recording-start` first/);
expect((err as Error).message).toContain("torn down");
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN
);
});
it("still reports a plain absence when no capture was reaped", async () => {
// The breadcrumb must not turn every "you never started one" into an
// accusation: disposing an idle session leaves nothing behind.
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
await instance.dispose();
const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e);
expect((err as Error).message).toContain("No active screen recording");
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_NO_ACTIVE_SESSION
);
});
it("is consumed once, so it cannot blame a later unrelated absence", async () => {
const { fresh } = await reapDuringCapture();
await stopCapture(fresh).catch(() => {});
const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e);
expect((err as Error).message).toContain("No active screen recording");
});
it("is dropped by a new recording, which would otherwise never consume it", async () => {
const { fresh } = await reapDuringCapture();
fakeStream();
fakeChild().exitOnStdinEnd();
await startAndSettle(fresh);
await fs.writeFile(fresh.outputFile!, Buffer.alloc(16, 1));
await stopCapture(fresh);
const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e);
expect((err as Error).message).toContain("No active screen recording");
});
});
});
describe("screen recording capture", () => {
it("encodes the paced frame pipe, stamps the session and registers the reminder", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
const result = await startAndSettle(api, { timeLimitSeconds: 60 });
expect(result).toMatchObject({ status: "recording", timeLimitSeconds: 60 });
expect(mockOpenStream).toHaveBeenCalledWith(STREAM_URL, expect.any(Number));
const [bin, args] = mockSpawn.mock.calls[0]!;
expect(bin).toBe("/fake/ffmpeg");
expect(args).toEqual(expect.arrayContaining(["-f", "image2pipe", "-framerate", "30"]));
// Output must be the file the caller was told about.
expect((args as string[]).at(-1)).toBe(result.outputFile);
expect(api.recordingActive).toBe(true);
expect(api.captureProcess).toBe(child as unknown as ChildProcess);
expect(getActiveScreenRecordings()).toHaveLength(1);
});
it("stamps the watermark in the same pass when the flag is on", async () => {
const api = await makeSession(iosDevice);
fakeStream();
fakeChild();
await startAndSettle(api, { watermark: true });
const args = mockSpawn.mock.calls[0]![1] as string[];
expect(args).toContain("-filter_complex");
// Geometry comes from the first frame's JPEG header (1320x2868), so the
// graph must carry a concrete crop box rather than a placeholder.
expect(args[args.indexOf("-filter_complex") + 1]).toContain("crop=378:116:40:2728");
expect(args).toContain("/tmp/fake-logo.png");
});
it("still records, and says so, when the frame size cannot be read", async () => {
const api = await makeSession(iosDevice);
// A frame whose JPEG header carries no SOF marker: nothing to size the
// watermark box from.
const headerless = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
fakeStream({ latest: headerless, waitForFirstFrame: vi.fn(async () => headerless) });
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { watermark: true });
const args = mockSpawn.mock.calls[0]![1] as string[];
expect(args).not.toContain("-filter_complex");
await fs.writeFile(api.outputFile!, Buffer.alloc(32, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.warning).toContain("watermark was not applied");
await fs.rm(outputFile, { force: true });
});
it("omits the filter graph entirely when the watermark is disabled", async () => {
const api = await makeSession(iosDevice);
fakeStream();
fakeChild();
await startAndSettle(api, { watermark: false });
const args = mockSpawn.mock.calls[0]![1] as string[];
expect(args).not.toContain("-filter_complex");
});
it("evens the raw base so an odd device resolution still encodes (no watermark)", () => {
// The watermark-off path has no filter graph to normalize the base, so the
// frame reaches libx264 directly. yuv420p rejects an odd width/height and
// leaves a 0-byte file (iPhone 16 / 15 Pro / 15 / 14 Pro stream at
// 1179x2556); the crop drops the odd edge pixel so any resolution records.
const args = ffmpegArgs({ outputFile: "/tmp/out.mp4", logoFile: null, graph: null });
const vf = args.indexOf("-vf");
expect(vf).toBeGreaterThan(-1);
expect(args[vf + 1]).toBe("crop=trunc(iw/2)*2:trunc(ih/2)*2:0:0");
expect(args).not.toContain("-filter_complex");
// the crop must precede the output file, not trail it
expect(vf).toBeLessThan(args.length - 1);
});
it("does not add a second base filter when a watermark graph already evens it", () => {
// buildWatermarkGraph handles the even-base crop itself, so the args must not
// also carry a -vf (ffmpeg rejects -vf alongside -filter_complex on one map).
const args = ffmpegArgs({
outputFile: "/tmp/out.mp4",
logoFile: "/tmp/logo.png",
graph: "[0:v]fps=30,split=2[base][under];[base][x]overlay[out]",
});
expect(args).not.toContain("-vf");
expect(args).toContain("-filter_complex");
});
it("fails the start when ffmpeg is not installed", async () => {
const api = await makeSession(iosDevice);
mockResolveFfmpeg.mockResolvedValue(null);
try {
await startAndSettle(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_FFMPEG_NOT_FOUND
);
}
expect(mockSpawn).not.toHaveBeenCalled();
expect(getActiveScreenRecordings()).toHaveLength(0);
expect(api.startPending).toBe(false);
});
it("fails the start (and closes the stream) when no frame ever arrives", async () => {
const api = await makeSession(iosDevice);
const streamError = new Error("no frame");
const stream = fakeStream({
waitForFirstFrame: vi.fn(async () => {
throw streamError;
}),
});
await expect(startAndSettle(api)).rejects.toThrow("no frame");
expect(stream.close).toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
expect(api.recordingActive).toBe(false);
});
it("fails the start when ffmpeg dies inside the fail-fast grace", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream();
const child = fakeChild();
const promise = startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 30,
watermark: false,
trimStatic: false,
});
await vi.advanceTimersByTimeAsync(1);
child.stderr.emit("data", Buffer.from("Unrecognized option 'bogus'\n"));
child.exit(1);
try {
await promise;
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_START_EXITED);
expect((err as Error).message).toContain("Unrecognized option");
}
expect(stream.close).toHaveBeenCalled();
expect(api.recordingActive).toBe(false);
expect(getActiveScreenRecordings()).toHaveLength(0);
});
it("rejects a second start while a recording is active", async () => {
const api = await makeSession(iosDevice);
fakeStream();
fakeChild();
await startAndSettle(api);
fakeStream();
fakeChild();
try {
await startAndSettle(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_ALREADY_ACTIVE);
}
});
it("rejects a start that overlaps another start's readiness window", async () => {
const api = await makeSession(iosDevice);
fakeStream();
fakeChild();
const first = startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 30,
watermark: false,
trimStatic: false,
});
// Second start lands while the first is still inside its grace.
await expect(
startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 30,
watermark: false,
trimStatic: false,
})
).rejects.toThrow(/already in flight|already running/i);
await vi.advanceTimersByTimeAsync(READY_GRACE_MS);
await first;
});
it("rejects a stop while a start is still in flight on the session", async () => {
const api = await makeSession(iosDevice);
fakeStream();
fakeChild();
const first = startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 30,
watermark: false,
trimStatic: false,
});
try {
await stopCapture(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_ALREADY_ACTIVE);
}
await vi.advanceTimersByTimeAsync(READY_GRACE_MS);
await first;
});
it("a superseded capture's exit no longer touches the new capture's session", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const first = fakeChild();
await startAndSettle(api);
// Simulate the first capture ending and being stopped, then a fresh start.
first.exitOnStdinEnd();
await fs.writeFile(api.outputFile!, Buffer.alloc(16, 1));
const firstOutput = api.outputFile!;
await stopCapture(api);
await fs.rm(firstOutput, { force: true });
fakeStream();
const second = fakeChild();
await startAndSettle(api);
const secondOutput = api.outputFile;
first.exit(0);
expect(api.recordingActive).toBe(true);
expect(api.captureProcess).toBe(second as unknown as ChildProcess);
expect(api.outputFile).toBe(secondOutput);
});
it("aborts a start (no spawn) once the session has been disposed", async () => {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
const api = instance.api;
api.disposed = true;
const stream = fakeStream();
try {
await startAndSettle(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN
);
// The error CODE is not the behaviour here — its enum name still says
// "shutting down", which is exactly the claim the message stopped making.
// A dispose is now far more often a `stop-all-simulator-servers` reaping
// this device than a process shutdown, and the two are indistinguishable
// from `api.disposed`. So the message must name both, and must not tell
// the caller a retry is pointless: on the teardown branch the device is
// usually still up. Asserted here because reverting the whole rewrite to
// the old one-liner otherwise leaves the suite green.
const message = (err as Error).message;
expect(message).toContain("stop-all-simulator-servers");
expect(message).toContain("nothing was recorded");
expect(message).toContain("start the recording again");
expect(message).toContain(IOS_UDID);
}
expect(mockSpawn).not.toHaveBeenCalled();
expect(stream.close).toHaveBeenCalled();
});
});
describe("frame pump", () => {
it("tops the encoder up to the frame count the elapsed time calls for", () => {
expect(framesDue(1_000, 1_000)).toBe(0);
expect(framesDue(1_000, 1_033)).toBe(0);
expect(framesDue(1_000, 1_034)).toBe(1);
expect(framesDue(1_000, 2_000)).toBe(30);
});
it("keeps re-emitting the last frame while the screen is static", async () => {
const api = await makeSession(iosDevice);
// A still screen produces exactly one frame and then nothing.
const stream = fakeStream({ frameCount: 1 });
const child = fakeChild();
await startAndSettle(api);
child.stdin.writes.length = 0;
await vi.advanceTimersByTimeAsync(1_000);
// ~1s of a 30fps timeline, all duplicates of the one frame we ever saw.
expect(child.stdin.writes.length).toBeGreaterThanOrEqual(25);
expect(child.stdin.writes.every((w) => w.equals(stream.latest!))).toBe(true);
});
it("writes newly arrived frames once the device draws again", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream();
const child = fakeChild();
await startAndSettle(api);
child.stdin.writes.length = 0;
const nextFrame = fakeJpeg(640, 480);
stream.latest = nextFrame;
await vi.advanceTimersByTimeAsync(200);
expect(child.stdin.writes.at(-1)!.equals(nextFrame)).toBe(true);
});
it("skips ticks while ffmpeg's pipe is backed up instead of buffering in Node", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
await startAndSettle(api);
child.stdin.writes.length = 0;
child.stdin.writableLength = 64 * 1024 * 1024; // encoder is far behind
await vi.advanceTimersByTimeAsync(1_000);
expect(child.stdin.writes).toHaveLength(0);
});
it("stops writing once the capture has been stopped", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
await fs.writeFile(api.outputFile!, Buffer.alloc(32, 1));
const outputFile = api.outputFile!;
await stopCapture(api);
const writesAtStop = child.stdin.writes.length;
await vi.advanceTimersByTimeAsync(1_000);
expect(child.stdin.writes).toHaveLength(writesAtStop);
await fs.rm(outputFile, { force: true });
});
});
describe("static-frame trimming", () => {
it("defaults on for a new session", async () => {
const api = await makeSession(iosDevice);
expect(api.trimStatic).toBe(true);
});
it("keeps only the grace window of a long static stretch", async () => {
const api = await makeSession(iosDevice);
fakeStream({ frameCount: 1 });
const child = fakeChild();
await startAndSettle(api, { trimStatic: true });
child.stdin.writes.length = 0;
await vi.advanceTimersByTimeAsync(5_000);
// ~1s of held frames (the grace), then nothing for the remaining 4s.
expect(child.stdin.writes.length).toBeGreaterThanOrEqual(27);
expect(child.stdin.writes.length).toBeLessThanOrEqual(34);
});
it("does NOT trim when the same stretch is recorded with trimStatic off", async () => {
const api = await makeSession(iosDevice);
fakeStream({ frameCount: 1 });
const child = fakeChild();
await startAndSettle(api, { trimStatic: false });
child.stdin.writes.length = 0;
await vi.advanceTimersByTimeAsync(5_000);
// Full 5s of the timeline, ~150 frames.
expect(child.stdin.writes.length).toBeGreaterThanOrEqual(140);
});
it("resumes emitting the moment the screen changes after a dead stretch", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream({ frameCount: 1 });
const child = fakeChild();
await startAndSettle(api, { trimStatic: true });
await vi.advanceTimersByTimeAsync(4_000); // long static — trimmed after 1s
const framesAfterStatic = api.framesWritten;
child.stdin.writes.length = 0;
const moved = fakeJpeg(640, 480); // a genuinely different picture
stream.latest = moved;
await vi.advanceTimersByTimeAsync(500);
// New content is written, and the skipped 3s did not become catch-up frames.
expect(child.stdin.writes.length).toBeGreaterThan(0);
expect(child.stdin.writes.every((w) => w.equals(moved))).toBe(true);
expect(api.framesWritten - framesAfterStatic).toBeLessThanOrEqual(20);
});
it("treats a byte-identical new frame as no change (stays trimmed)", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream({ frameCount: 1 });
const child = fakeChild();
await startAndSettle(api, { trimStatic: true });
await vi.advanceTimersByTimeAsync(2_000); // trimmed after the grace
child.stdin.writes.length = 0;
// A NEW buffer object, but the same pixels: must not un-trim.
stream.latest = fakeJpeg(1320, 2868);
await vi.advanceTimersByTimeAsync(1_000);
expect(child.stdin.writes).toHaveLength(0);
});
it("reports the trimmed length plus the wall clock and how much was cut", async () => {
const api = await makeSession(iosDevice);
fakeStream({ frameCount: 1 });
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { trimStatic: true });
await vi.advanceTimersByTimeAsync(5_000); // 5s wall clock, ~1s of kept frames
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.durationMs).toBeGreaterThanOrEqual(800);
expect(result.durationMs).toBeLessThanOrEqual(1_300);
expect(result.wallClockMs).toBeGreaterThanOrEqual(4_800);
expect(result.trimmedMs).toBeGreaterThan(3_000);
await fs.rm(outputFile, { force: true });
});
it("omits wallClockMs/trimmedMs when trimming is on but nothing was static", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream({ frameCount: 1 });
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { trimStatic: true });
// Drive continuous motion: a fresh, distinct frame every tick so the pump
// never crosses the static grace and trims nothing.
const frameIntervalMs = 1000 / 30;
for (let i = 0; i < 90; i++) {
// A distinct picture each tick (trailing byte differs) — never static.
stream.latest = Buffer.concat([fakeJpeg(1320, 2868), Buffer.from([i])]);
await vi.advanceTimersByTimeAsync(frameIntervalMs);
}
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
// Nothing was trimmed, so the trim-only fields must be absent — a small
// rounding gap between framesWritten and the wall clock is not "trimming".
expect(result.wallClockMs).toBeUndefined();
expect(result.trimmedMs).toBeUndefined();
await fs.rm(outputFile, { force: true });
});
it("omits wallClockMs/trimmedMs when trimming is off", async () => {
const api = await makeSession(iosDevice);
fakeStream({ frameCount: 1 });
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { trimStatic: false });
await vi.advanceTimersByTimeAsync(2_000);
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.wallClockMs).toBeUndefined();
expect(result.trimmedMs).toBeUndefined();
await fs.rm(outputFile, { force: true });
});
});
describe("touch visualizer", () => {
it("enables the overlay at start and restores it to off at stop", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
const pointer = fakePointer();
await startAndSettle(api, { pointer });
expect(pointer.enable).toHaveBeenCalledTimes(1);
expect(pointer.disable).not.toHaveBeenCalled();
expect(api.pointerFailed).toBe(false);
expect(api.pointerDisable).toBe(pointer.disable);
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(pointer.disable).toHaveBeenCalledTimes(1);
expect(api.pointerDisable).toBeNull();
expect(result.warning).toBeUndefined();
await fs.rm(outputFile, { force: true });
});
it("restores the overlay when the time-limit cap fires", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
const pointer = fakePointer();
await startAndSettle(api, { timeLimitSeconds: 5, pointer });
await vi.advanceTimersByTimeAsync(5_000); // cap fires
expect(pointer.disable).toHaveBeenCalledTimes(1);
expect(api.recordingTimedOut).toBe(true);
});
it("restores the overlay when the encoder dies unexpectedly", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
const pointer = fakePointer();
await startAndSettle(api, { pointer });
child.exit(1); // crash mid-capture
expect(pointer.disable).toHaveBeenCalledTimes(1);
expect(api.recordingExitedUnexpectedly).toBe(true);
});
it("observes an encoder crash that lands during the pointer-enable await", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
// Park enable() in flight so the start suspends exactly in the window
// between ffmpeg readiness and the exit-handler registration — the gap where
// a crash would go unobserved unless the `exit` listener is armed first.
let releaseEnable!: () => void;
const enableParked = new Promise<void>((resolve) => (releaseEnable = resolve));
const enable = vi.fn(async () => {
await enableParked;
return true;
});
const pointer = { enable, disable: vi.fn(async () => {}) } satisfies PointerControl;
const promise = startCapture(api, {
streamUrl: STREAM_URL,
timeLimitSeconds: 180,
watermark: false,
trimStatic: false,
pointer,
});
promise.catch(() => {});
// Clear the fail-fast grace so readiness resolves and the start parks at
// `await enable()`.
await vi.advanceTimersByTimeAsync(READY_GRACE_MS);
expect(enable).toHaveBeenCalledTimes(1); // enable is now parked in flight
child.exit(1); // ffmpeg dies WHILE the enable await is still suspended
// The death must be seen: with the exit listener armed before the await, the
// crash flips the recording into its unexpected-exit recovery state so a
// later stop warns and returns the (truncated) file instead of a silent one.
expect(api.recordingExitedUnexpectedly).toBe(true);
expect(api.pendingRetrieval).toBe(true);
releaseEnable();
await promise;
});
it("restores the overlay on session dispose", async () => {
const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, {
device: iosDevice,
} as never);
const api = instance.api;
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
const pointer = fakePointer();
await startAndSettle(api, { pointer });
await instance.dispose();
expect(pointer.disable).toHaveBeenCalledTimes(1);
expect(api.pointerDisable).toBeNull();
});
it("warns at stop when the overlay could not be enabled", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
const pointer = fakePointer(false); // sim-server refused the toggle
await startAndSettle(api, { pointer });
expect(api.pointerFailed).toBe(true);
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
// Even a failed enable is still restored to off, best-effort.
expect(pointer.disable).toHaveBeenCalled();
expect(result.warning).toContain("touch visualizer could not be enabled");
expect(api.pointerFailed).toBe(false); // reset for the next recording
await fs.rm(outputFile, { force: true });
});
it("leaves the pointer untouched when no control is supplied", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api); // showTouches off → no PointerControl
expect(api.pointerDisable).toBeNull();
expect(api.pointerFailed).toBe(false);
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.warning).toBeUndefined();
await fs.rm(outputFile, { force: true });
});
it("clears a stale failed-enable flag at start so a later showTouches:false recording is clean", async () => {
const api = await makeSession(iosDevice);
// A cap- or crash-ended recording leaves `pointerFailed` set: only stop and a
// fresh start clear it, and neither the cap nor the crash path runs stop's
// reset. Set that residue directly instead of driving a real cap-then-start.
// On this branch such a start is still admitted (assertNoActiveRecording gates
// only on recordingActive/startPending/stopPending), but #517's guard — already
// on origin/feat/screen-recording, not yet in this base — additionally rejects a
// start while a finalized recording awaits retrieval (`pendingRetrieval`), so a
// cap-then-start would throw once the stack rebases. Driving the flag in
// directly keeps this test valid under both guards; it asserts only that a fresh
// start scrubs the flag, so an overlay-free recording never inherits a spurious
// touch-visualizer warning.
api.pointerFailed = true;
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api); // showTouches off → no PointerControl
expect(api.pointerFailed).toBe(false); // start's reset scrubbed the stale flag
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.warning).toBeUndefined();
await fs.rm(outputFile, { force: true });
});
});
describe("touch visualizer wire protocol", () => {
const fakeApi = { apiUrl: "http://127.0.0.1:65500" } as SimulatorServerApi;
afterEach(() => {
vi.unstubAllGlobals();
});
it("setPointerVisible POSTs {show} and reads status ok", async () => {
const fetchMock = vi.fn(
async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ status: "ok" }))
);
vi.stubGlobal("fetch", fetchMock);
await expect(setPointerVisible(fakeApi, true)).resolves.toBe(true);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("http://127.0.0.1:65500/api/pointer");
expect(init).toMatchObject({ method: "POST" });
expect(JSON.parse(init!.body as string)).toEqual({ show: true });
});
it("setPointerTrail POSTs {trail}", async () => {
const fetchMock = vi.fn(
async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ status: "ok" }))
);
vi.stubGlobal("fetch", fetchMock);
await expect(setPointerTrail(fakeApi, 8)).resolves.toBe(true);
expect(JSON.parse(fetchMock.mock.calls[0]![1]!.body as string)).toEqual({ trail: 8 });
});
it("returns false on an error response body", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ error: "pointer feature disabled" })))
);
await expect(setPointerVisible(fakeApi, true)).resolves.toBe(false);
});
it("returns false when the request throws (unreachable / aborted)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("ECONNREFUSED");
})
);
await expect(setPointerVisible(fakeApi, false)).resolves.toBe(false);
});
it("issues disable's show:false only after an in-flight enable's show:true", async () => {
// Model a dispose racing the enable await: enable's `show:true` is parked
// in flight when disable is called. The control must serialize them so the
// overlay ends OFF — otherwise disable's `show:false` is issued first and is
// overtaken by the later `show:true`, leaving the overlay stuck on.
const ops: Array<Record<string, unknown>> = [];
let releaseShowTrue!: () => void;
const showTrueParked = new Promise<void>((resolve) => (releaseShowTrue = resolve));
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(init!.body as string) as Record<string, unknown>;
if (body.show === true) await showTrueParked; // hold enable's overlay toggle
ops.push(body);
return new Response(JSON.stringify({ status: "ok" }));
});
vi.stubGlobal("fetch", fetchMock);
const control = makePointerControl(fakeApi);
const enablePromise = control.enable();
// enable's trail POST resolves; its show:true is now parked. Race disable in.
const disablePromise = control.disable();
// Flush microtasks: an unserialized disable would fire show:false here.
for (let i = 0; i < 20; i++) await Promise.resolve();
expect(ops).toEqual([{ trail: 8 }]); // no show yet
releaseShowTrue();
await Promise.all([enablePromise, disablePromise]);
// Serialized: trail, then show:true, then show:false last → overlay ends off.
expect(ops).toEqual([{ trail: 8 }, { show: true }, { show: false }]);
});
it("disable() still issues show:false when no enable is in flight", async () => {
// The ordinary stop path: enable() has already resolved (enabling === null),
// so disable() takes the non-parked branch. It must still send show:false —
// otherwise a plain stop would leave the overlay on for the next screenshot.
const ops: Array<Record<string, unknown>> = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
ops.push(JSON.parse(init!.body as string) as Record<string, unknown>);
return new Response(JSON.stringify({ status: "ok" }));
});
vi.stubGlobal("fetch", fetchMock);
const control = makePointerControl(fakeApi);
await control.enable(); // fully settles → enabling back to null
await control.disable(); // idle path, nothing to await first
expect(ops).toEqual([{ trail: 8 }, { show: true }, { show: false }]);
});
it("enable() reflects the show result even when the trail request fails", async () => {
// The trail is only cosmetic, so a non-ok trail must not make enable() report
// the overlay as failed: enable() returns the show toggle's result. Otherwise
// stop would warn "touch visualizer could not be enabled" on a video that does
// show touches.
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(init!.body as string) as Record<string, unknown>;
const payload = "trail" in body ? { error: "trail unsupported" } : { status: "ok" };
return new Response(JSON.stringify(payload));
})
);
const control = makePointerControl(fakeApi);
await expect(control.enable()).resolves.toBe(true);
});
});
describe("screen recording stop", () => {
it("finalizes the file, clears the session state and clears the reminder", async () => {
const api = await makeSession(androidDevice);
const stream = fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
await fs.writeFile(api.outputFile!, Buffer.alloc(1024, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.outputFile).toBe(outputFile);
expect(result.sizeBytes).toBe(1024);
expect(result.durationMs).not.toBeNull();
expect(result.warning).toBeUndefined();
expect(child.stdin.ended).toBe(true);
expect(stream.close).toHaveBeenCalled();
expect(api.recordingActive).toBe(false);
expect(api.outputFile).toBeNull();
expect(api.pumpTimer).toBeNull();
expect(getActiveScreenRecordings()).toHaveLength(0);
await fs.rm(outputFile, { force: true });
});
it("leaves the finalized video ON DISK after a successful stop", async () => {
// Invariant: screen-recording-stop registers the artifact AFTER stopCapture
// returns, so a successful stop must never delete its own output. Guards
// against any fail-open cleanup (a "delete unless a success flag is set"
// finally) that a later refactor could trip into wiping every recording.
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
const outputFile = api.outputFile!;
await fs.writeFile(outputFile, Buffer.alloc(4096, 1)); // a real, non-empty video
const result = await stopCapture(api);
expect(result.outputFile).toBe(outputFile);
const sizeOnDisk = await fs
.stat(outputFile)
.then((s) => s.size)
.catch(() => -1);
await fs.rm(outputFile, { force: true }).catch(() => {});
expect(sizeOnDisk).toBe(4096);
});
it("stop with no session fails with SCREEN_RECORDING_NO_ACTIVE_SESSION", async () => {
const api = await makeSession(iosDevice);
try {
await stopCapture(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.SCREEN_RECORDING_NO_ACTIVE_SESSION
);
}
});
it("ends the capture at the time-limit cap and flips the reminder to finalized", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { timeLimitSeconds: 5 });
await vi.advanceTimersByTimeAsync(5_000);
expect(child.stdin.ended).toBe(true);
expect(stream.close).toHaveBeenCalled();
expect(api.recordingActive).toBe(false);
expect(api.recordingTimedOut).toBe(true);
expect(api.pendingRetrieval).toBe(true);
const [reminder] = getActiveScreenRecordings();
expect(reminder?.finalizedReason).toContain("5s time limit");
});
it("rejects a start while a capped recording still awaits retrieval, keeping the first video reachable", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { timeLimitSeconds: 5 });
await vi.advanceTimersByTimeAsync(5_000); // cap fires -> pendingRetrieval
expect(api.pendingRetrieval).toBe(true);
const firstOutput = api.outputFile!;
const firstLogo = api.logoFile;
// Admitting a start here would overwrite outputFile/logoFile and orphan the
// finalized-but-unretrieved video (the state stop still recovers from), so
// the guard must reject it and point the agent at stop instead.
fakeStream();
fakeChild();
try {
await startAndSettle(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_ALREADY_ACTIVE);
}
// The first recording's session pointers are untouched -> still retrievable.
expect(api.outputFile).toBe(firstOutput);
expect(api.logoFile).toBe(firstLogo);
expect(api.pendingRetrieval).toBe(true);
});
it("durationMs reflects the capture length, not the idle time after the cap", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { timeLimitSeconds: 5 });
await vi.advanceTimersByTimeAsync(5_000); // cap fires
await vi.advanceTimersByTimeAsync(30_000); // agent notices much later
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
expect(result.durationMs).toBeGreaterThanOrEqual(5_000);
expect(result.durationMs).toBeLessThan(6_000);
expect(result.warning).toContain("time limit");
await fs.rm(outputFile, { force: true });
});
it("recovers the video when ffmpeg died before stop, with a warning", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
await startAndSettle(api);
await fs.writeFile(api.outputFile!, Buffer.alloc(128, 1));
const outputFile = api.outputFile!;
child.exit(1); // encoder crashed mid-capture
expect(api.pendingRetrieval).toBe(true);
expect(api.recordingExitedUnexpectedly).toBe(true);
const result = await stopCapture(api);
expect(result.outputFile).toBe(outputFile);
expect(result.warning).toContain("exited before stop");
expect(getActiveScreenRecordings()).toHaveLength(0);
await fs.rm(outputFile, { force: true });
});
it("warns when the frame stream dropped during the recording", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
stream.error = new Error("frame stream aborted");
const result = await stopCapture(api);
expect(result.warning).toContain("frame stream");
await fs.rm(outputFile, { force: true });
});
it("still reports a frame-stream drop when the cap fired after the drop", async () => {
const api = await makeSession(iosDevice);
const stream = fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api, { timeLimitSeconds: 5 });
// Stream drops mid-recording, THEN the cap fires. The cap's finalize nulls
// api.frameStream, so a stop that reads api.frameStream.error afterwards
// would lose the drop entirely and never emit the freeze hint.
stream.error = new Error("frame stream aborted");
await vi.advanceTimersByTimeAsync(5_000); // cap fires, nulls frameStream
await fs.writeFile(api.outputFile!, Buffer.alloc(64, 1));
const outputFile = api.outputFile!;
const result = await stopCapture(api);
// Both the cap notice and the freeze hint must survive.
expect(result.warning).toContain("time limit");
expect(result.warning).toContain("frame stream");
await fs.rm(outputFile, { force: true });
});
it("stop fails loudly when the recording left no file behind", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
try {
await stopCapture(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_OUTPUT_MISSING);
}
// The session must still be startable after a failed stop.
expect(api.recordingActive).toBe(false);
expect(api.stopPending).toBe(false);
});
it("removes the empty container rather than orphaning it when a stop fails", async () => {
const api = await makeSession(iosDevice);
fakeStream();
const child = fakeChild();
child.exitOnStdinEnd();
await startAndSettle(api);
// ffmpeg opened its output with `-y` but wrote nothing (odd-resolution
// encode abort, disk full mid-header, ...): a real 0-byte file is on disk.
const outputFile = api.outputFile!;
await fs.writeFile(outputFile, Buffer.alloc(0));
try {
await stopCapture(api);
expect.unreachable();
} catch (err) {
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.SCREEN_RECORDING_OUTPUT_MISSING);
}
// The dead 0-byte temp must not be left behind in os.tmpdir.
await expect(fs.access(outputFile)).rejects.toThrow();
expect(api.recordingActive).toBe(false);
expect(api.stopPending).toBe(false);
});
});
describe("readJpegDimensions", () => {
it("reads the frame size from the SOF marker", () => {
expect(readJpegDimensions(fakeJpeg(1320, 2868))).toEqual({ width: 1320, height: 2868 });
expect(readJpegDimensions(fakeJpeg(1080, 2220))).toEqual({ width: 1080, height: 2220 });
});
it("skips leading segments before the SOF", () => {
const app0 = Buffer.alloc(20);
app0.writeUInt16BE(0xffd8, 0);
app0.writeUInt16BE(0xffe0, 2); // APP0
app0.writeUInt16BE(16, 4); // segment length, skipped over
const jpeg = Buffer.concat([app0.subarray(0, 20), fakeJpeg(800, 600).subarray(2)]);
expect(readJpegDimensions(jpeg)).toEqual({ width: 800, height: 600 });
});
it("returns null when there is no frame header", () => {
expect(readJpegDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xd9]))).toBeNull();
});
it("skips 0xFF fill bytes before a marker (T.81 B.1.1.2)", () => {
// A decoder-valid JPEG may pad any number of 0xFF fill bytes before a
// marker. Splice extras in ahead of the SOF: ffprobe/libjpeg read this
// fine, so the dimension reader must too rather than bailing to null and
// silently dropping the watermark.
const base = fakeJpeg(640, 480);
const withFill = Buffer.concat([
base.subarray(0, 2), // SOI
Buffer.from([0xff, 0xff, 0xff]), // fill bytes before the SOF marker
base.subarray(2), // SOF0 + payload + EOI
]);
expect(readJpegDimensions(withFill)).toEqual({ width: 640, height: 480 });
});
});