9e37213cd4
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
165 lines
8.1 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|