Files
improvise/harness/tests/landing.spec.js
T
Edward Langley 9e37213cd4 fix(docs): re-record the four casts from a script, with no shell to leak
improvise-2oxt. All four casts under docs/casts/ opened and closed on
`(0) Mac:<hostname> ~gf/u/edwlan/improvise` — a real hostname and home directory
on what improvise-kh8 made the site root — and `formulas.cast` and `import.cast`
showed that prompt as their visible OPENING frame. drill.cast also predated the
records columns entirely, showing a single `Value` column where the current
build draws a column per measure plus `+cat` and `+`.

The repair is structural, not careful. `docs/record-casts.py` drives `improvise`
on a pty it owns and writes asciicast v2 itself, so `improvise` is the pty's ONLY
child and there is no shell for a prompt to leak from — nothing to remember to
clean, and nothing that comes back when somebody re-records on another machine.
Three other things stopped being accidents with it: the geometry is an input
(120x37, the numbers docs/index.html already spelled, so they did not change),
the pacing is a per-step table, and the header carries no `env` (which held
$SHELL) and no `timestamp` (the recording machine's clock).

Two decisions that each cost a re-record. No `:q` is typed at the end: the player
loops, so the last frame is also the frame before the first, and a minibuffer
reading `:q` is what it would show — the process is signalled instead. And each
scenario had to be ARRANGED so the thing it names is visible: the drill cast
drilled into a cell with no records behind it, and the pivot cast pressed `c` on
a category already on the column axis. Both recorded cleanly and demonstrated
nothing — true frames about the wrong thing — and neither was visible from the
keystroke list.

That is the method worth keeping: a cast can be replayed through a small ANSI
reconstruction and READ AS TEXT, so "what does frame N show" has an answer rather
than a guess. It is how the `+cat`/`+` columns in the new drill.cast were
confirmed present, and how both dud scenarios were caught.

The guard is `landing.spec.js`, asserted over the cast FILES: no header carries
`env` or `timestamp`, the geometry is the one the page configures, and no chunk
contains an OSC window-title sequence — the leak's signature, emitted by a shell
once per prompt with the cwd in it and by `improvise` never. Asserted over the
file rather than the rendering because a player draws a prompt as ordinary text,
so a screenshot assertion would have to know what the hostname had been. BOTH
clauses were injected separately, because the `env` one would otherwise mask the
OSC one: the old cast fails on `env`, and the old cast with `env` stripped fails
on the title sequence.

docs/index.html loses the `p.dated` note admitting the drill cast was out of
date, which is now false.

Tests: 33 harness + 3 skipped (was 28 + 3). Rust and node unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:52:24 -07:00

165 lines
8.1 KiB
JavaScript

// The landing page (improvise-e61), which the harness has been serving at the
// site root since improvise-kh8 and nothing asserted anything about
// (improvise-bgtf). Chromium only: nothing here is engine-dependent.
import { test, expect } from "@playwright/test";
const CASTS = ["pivot", "drill", "formulas", "import"];
test.describe("the landing page", () => {
// COUNTING THE PLAYERS PROVES NOTHING, and that is the whole reason this
// spec exists rather than a one-liner: `autoPlay` is false, so the player
// fetches no cast until someone presses play. A page whose every cast path
// 404s constructs four players, throws no page error, and looks identical
// to a working one. Verified by injecting exactly that — evidence file.
test("every cast on the page is fetched and plays", async ({ page }) => {
const fetched = new Map();
page.on("response", (res) => {
const match = /\/([a-z]+)\.cast(\?|$)/.exec(res.url());
if (match) fetched.set(match[1], res.status());
});
const errors = [];
page.on("pageerror", (err) => errors.push(String(err)));
await page.goto("/index.html");
// The prose, so a blank page with four players in it is not a pass.
await expect(page.locator("h1")).toContainText("improvise");
await expect(page.locator(".ap-player")).toHaveCount(CASTS.length);
// Nothing has been fetched yet: this is the state the naive check mistook
// for a working page, asserted so the evidence below means something.
expect([...fetched.keys()]).toEqual([]);
for (const name of CASTS) {
const player = page.locator(`#cast-${name}`);
await player.locator(".ap-play-button").click();
// The terminal is EMPTY until frames are applied, so this is the
// assertion a 404 fails: `.ap-line` elements exist only once the
// player has a cast to lay out.
await expect(player.locator(".ap-line").first()).toBeVisible({ timeout: 20_000 });
// And what it laid out is this recording, not an empty grid of rows:
// every cast starts a real improvise session.
await expect(player.locator(".ap-terminal")).toContainText("improvise ·", {
timeout: 20_000,
});
}
// Four distinct casts, each answered 200 — the per-cast half, which the
// rendering cannot give: three players showing the same recording would
// satisfy every assertion above.
expect([...fetched.keys()].sort()).toEqual([...CASTS].sort());
expect([...fetched.values()]).toEqual(CASTS.map(() => 200));
expect(errors, "the page threw").toEqual([]);
});
// The page is the front door and the app is what it sends you to (the
// improvise-kh8 ruling). A dead link there is invisible from the app's own
// specs, which navigate straight to `/app/index.html`.
test("the front door reaches the app it advertises", async ({ page }) => {
await page.goto("/index.html");
const demo = page.locator("footer a", { hasText: "Live browser demo" });
await expect(demo).toHaveAttribute("href", "./app/");
await demo.click();
// Followed rather than asserted as a string: the assembled tree is what
// decides whether `./app/` resolves, and `assemble.sh` is free to move.
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#status")).toContainText("in-page worker");
});
// improvise-o31u. Since improvise-kh8 this page is the SITE ROOT, so the
// first thing a phone visitor sees is four panels of terminal. The player
// derives its font size from the container width, and 120 columns into the
// ~356px inside the page's gutters gave 4.83px — measured, not eyeballed.
//
// The instrument matters: `getBoundingClientRect` reports a height for a
// `content-visibility`-hidden child and heading text overflows its box
// without widening it, so neither "the element is there" nor a box
// measurement can see this. The COMPUTED FONT SIZE is what a reader sees.
test("the casts are legible on a phone, and the page still does not scroll sideways", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/index.html");
await expect(page.locator(".ap-player")).toHaveCount(CASTS.length);
for (const name of CASTS) {
const player = page.locator(`#cast-${name}`);
await player.locator(".ap-play-button").click();
await expect(player.locator(".ap-line").first()).toBeVisible({ timeout: 20_000 });
const px = await player
.locator(".ap-terminal")
.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(px, `${name}.cast renders at ${px}px on a 390px viewport`).toBeGreaterThanOrEqual(
10,
);
}
// The trade this fix must NOT make: a page that scrolls sideways breaks
// the whole document on a phone rather than one section of it. The
// overflow has to be contained inside `.player`.
const scrolls = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
player: document.querySelector("#cast-pivot").scrollWidth >
document.querySelector("#cast-pivot").clientWidth,
}));
expect(scrolls.page).toBe(0);
expect(scrolls.player, "the cast must be the thing that scrolls").toBe(true);
});
// The other half: desktop is not collateral. 13.11px at 1280 was fine and
// nothing about this fix is allowed to touch it.
test("a desktop viewport is unchanged by the phone fix", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/index.html");
const player = page.locator("#cast-pivot");
await player.locator(".ap-play-button").click();
await expect(player.locator(".ap-line").first()).toBeVisible({ timeout: 20_000 });
const px = await player
.locator(".ap-terminal")
.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(px).toBeGreaterThan(12);
expect(
await page.evaluate(
() =>
document.querySelector("#cast-pivot").scrollWidth <=
document.querySelector("#cast-pivot").clientWidth,
),
"a desktop player has nothing to scroll",
).toBe(true);
});
// improvise-2oxt. The four casts used to be `asciinema rec` of a SHELL, and
// the shell was the recorder's own: every one of them opened and closed on
// `(0) Mac:<hostname> ~gf/u/edwlan/improvise`, on what improvise-kh8 made the
// site root. `docs/record-casts.py` drives `improvise` as the pty's only
// child, so there is no shell for a prompt to leak from.
//
// Asserted over the FILE rather than the rendering, because that is where the
// leak lives: a player draws a prompt as ordinary text and a screenshot
// assertion would have to know what the hostname was.
test("no cast carries the recording machine's shell, clock or geometry", async ({
request,
}) => {
for (const name of CASTS) {
const lines = (await (await request.get(`/casts/${name}.cast`)).text()).trim().split("\n");
const header = JSON.parse(lines[0]);
// `env` carried $SHELL and $TERM off the recording machine; `timestamp`
// carried its clock. Neither is anything a visitor is owed.
expect(header.env, `${name}.cast carries the recorder's environment`).toBeUndefined();
expect(header.timestamp, `${name}.cast carries a recording clock`).toBeUndefined();
// The geometry is an INPUT now, and docs/index.html spells the same
// numbers: a 30-row player over a 37-row cast clips the tile bar and the
// status line, which is exactly where the interesting output is.
expect([header.width, header.height], `${name}.cast geometry`).toEqual([120, 37]);
// The OSC window-title sequence is the leak's signature: `improvise`
// emits none and a shell emits one per prompt, carrying the cwd. Checked
// over EVERY chunk, not just the first — the old casts leaked at the end
// as well, when the session exited back to the prompt.
const body = lines.slice(1).join("\n");
expect(body.includes("\\u001b]"), `${name}.cast sets a window title`).toBe(false);
}
});
});