From b09e7a897d2263f4bb3e99b9bb3907d98d655a22 Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Mon, 12 Oct 2026 22:59:17 -0600 Subject: [PATCH 1/6] ci(01): add a CI workflow running the repo's checks on push and pull request --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e57a1f80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["22.19", "24"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + - run: npm ci + - run: npm run check + - run: npm run lint + - run: npm run format:check + - run: npm test From 4363c977adc6f0ea37f4a492e8b6b4679874e9cf Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Mon, 12 Oct 2026 23:11:55 -0600 Subject: [PATCH 2/6] ci(01): push runs only on the base branches, and package.json names npm so setup-node caches --- .github/workflows/ci.yml | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e57a1f80..4aeed70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI on: push: + branches: + - main + - oh-my-pocket pull_request: permissions: diff --git a/package.json b/package.json index 1f4fca25..62298e95 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "engines": { "node": ">=22.19" }, + "packageManager": "npm@12.2.0", "scripts": { "start": "node bin/pi-pocket.js", "dev": "node bin/pi-pocket.js --cwd .", From e58cf0b2254cb486d718f3b5d6f1791c10311f50 Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Tue, 13 Oct 2026 09:23:41 -0600 Subject: [PATCH 3/6] test(01): the picker's Routers group is not a model row --- test/visible-models-ui.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/visible-models-ui.test.ts b/test/visible-models-ui.test.ts index 390d72ec..29f7acd3 100644 --- a/test/visible-models-ui.test.ts +++ b/test/visible-models-ui.test.ts @@ -1,6 +1,6 @@ // The Providers sheet's model switches in a real browser at phone size, where this machine has Chromium: the owner // turns some of a provider's models on, so only those reach the model menu in a session, and a provider they did not -// touch still offers every model it has. +// touch still offers every model it has. The menu also lists the routers above the models, which this test does not count. import { type App, cleanUp, home, newSession, root, until, work } from "./helpers.ts"; import assert from "node:assert/strict"; import { mkdtempSync } from "node:fs"; @@ -136,7 +136,7 @@ async function menu(): Promise { "the model menu", ); const rows = await inPage( - `return JSON.stringify([...document.querySelectorAll(".pop-menu-list .pop-menu-row")].map((row) => row.title.split(" · ")[0]))`, + `return JSON.stringify([...document.querySelectorAll(".pop-menu-list .pop-menu-row:not(.router-row)")].map((row) => row.title.split(" · ")[0]))`, ); await tap(`document.querySelector(".pop-overlay")`, "the menu's backdrop"); From 68d29b785489c83789c0d16bef9e5f138282a004 Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Tue, 13 Oct 2026 09:24:07 -0600 Subject: [PATCH 4/6] ci(01): name the runner's Chrome, unsandboxed, and fail when it is gone --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aeed70f..ba9bd2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,14 @@ jobs: fail-fast: false matrix: node: ["22.19", "24"] + env: + # The image's own `chromium` is a snapshot build with none of its dependencies installed and no + # AppArmor profile, so Ubuntu 24.04's restriction on unprivileged user namespaces kills it as it + # starts, and the browser tests die with "The browser closed." (run 38350258246). Naming the deb's + # Google Chrome takes the browser out of PATH order and leaves the choice readable here. + PI_POCKET_BROWSER: /usr/bin/google-chrome + # The flag the README documents for a machine with no sandbox, which a CI runner is. + PI_POCKET_BROWSER_ARGS: --no-sandbox steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -26,4 +34,8 @@ jobs: - run: npm run check - run: npm run lint - run: npm run format:check + # With PI_POCKET_BROWSER naming something that is not a file, findBrowser() returns undefined and + # every browser test skips itself: a green run with those tests doing nothing. This step names the + # browser the job uses and fails when the image stops shipping it. + - run: test -x /usr/bin/google-chrome - run: npm test From 57d7d69ba9cef5aff93782c1e94f42658ae4734a Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Tue, 13 Oct 2026 09:44:33 -0600 Subject: [PATCH 5/6] chore(01): format the three files CI's format:check caught The base branch carried three files Prettier would rewrite, so format:check failed on every run of this workflow: .ome/project.json, test/browser.test.ts and test/session-row-ui.test.ts. Whitespace only. --- .ome/project.json | 6 +- test/browser.test.ts | 144 +++++++++++++++++------------------- test/session-row-ui.test.ts | 102 ++++++++++++------------- 3 files changed, 119 insertions(+), 133 deletions(-) diff --git a/.ome/project.json b/.ome/project.json index d4a5eeca..351a3e0c 100644 --- a/.ome/project.json +++ b/.ome/project.json @@ -1,5 +1,5 @@ { - "projectId": "01M4TVK00A6JQ1Z2DFFXFG1J15", - "stateBranch": "__ome_state__", - "createdAt": "2026-10-13T14:45:11.818Z" + "projectId": "01M4TVK00A6JQ1Z2DFFXFG1J15", + "stateBranch": "__ome_state__", + "createdAt": "2026-10-13T14:45:11.818Z" } diff --git a/test/browser.test.ts b/test/browser.test.ts index ca439883..0550ace6 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -828,88 +828,80 @@ test("the browser tool says so when this machine has no browser", async () => { // ─── 12-c1: screenshot size contract at the unzoomed baseline ─── -test( - "screenshot width/height matches the JPEG size at baseline (12-c1)", - real, - async () => { - const saved = new Map(); - const browsers = new Browsers({ - dataDir: mkdtempSync(join(root, "screenshot-")), - load: async (id) => saved.get(id), - save: (id, value) => saved.set(id, value), - }); - - try { - const page = await browsers.open(1); +test("screenshot width/height matches the JPEG size at baseline (12-c1)", real, async () => { + const saved = new Map(); + const browsers = new Browsers({ + dataDir: mkdtempSync(join(root, "screenshot-")), + load: async (id) => saved.get(id), + save: (id, value) => saved.set(id, value), + }); - await page.navigate(`${base}/`); - await until(() => page.title === "Smoke test", "the title"); + try { + const page = await browsers.open(1); - // Probe the baseline CSS layout size. - const probeStr = await page.evaluate( - "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", - ); - const probe = JSON.parse(probeStr) as { w: number; cw: number; d: number; v: number }; + await page.navigate(`${base}/`); + await until(() => page.title === "Smoke test", "the title"); - // Zoom actions: in headless Chromium these are unreachable, but we probe - // to document why only the baseline contract is asserted. - await page.press("Control+Shift+-"); - const afterZoomIn = JSON.parse( - await page.evaluate( - "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", - ), - ) as { w: number; cw: number; d: number; v: number }; + // Probe the baseline CSS layout size. + const probeStr = await page.evaluate( + "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", + ); + const probe = JSON.parse(probeStr) as { w: number; cw: number; d: number; v: number }; - await page.press("Control+-"); - const afterZoomOut = JSON.parse( - await page.evaluate( - "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", - ), - ) as { w: number; cw: number; d: number; v: number }; + // Zoom actions: in headless Chromium these are unreachable, but we probe + // to document why only the baseline contract is asserted. + await page.press("Control+Shift+-"); + const afterZoomIn = JSON.parse( + await page.evaluate( + "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", + ), + ) as { w: number; cw: number; d: number; v: number }; - // Zoom readings are identical to baseline — keyboard zoom is unreachable - // in headless Chromium on this machine. The test therefore only asserts - // the baseline contract. - assert.deepEqual( - afterZoomIn, - probe, - "zoom-in did not change readings (unreachable in headless)", - ); - assert.deepEqual( - afterZoomOut, - probe, - "zoom-out did not change readings (unreachable in headless)", - ); + await page.press("Control+-"); + const afterZoomOut = JSON.parse( + await page.evaluate( + "JSON.stringify({ w: innerWidth, cw: document.documentElement.clientWidth, d: devicePixelRatio, v: visualViewport?.scale })", + ), + ) as { w: number; cw: number; d: number; v: number }; + + // Zoom readings are identical to baseline — keyboard zoom is unreachable + // in headless Chromium on this machine. The test therefore only asserts + // the baseline contract. + assert.deepEqual( + afterZoomIn, + probe, + "zoom-in did not change readings (unreachable in headless)", + ); + assert.deepEqual( + afterZoomOut, + probe, + "zoom-out did not change readings (unreachable in headless)", + ); - // Take the screenshot and measure its JPEG. - const shot = await page.screenshot(); - const img = jpegSize(shot.data); + // Take the screenshot and measure its JPEG. + const shot = await page.screenshot(); + const img = jpegSize(shot.data); - // Core contract: bitmap width/height equals reported width/height × scale. - assert.equal( - img.width, - Math.round(shot.width * shot.scale), - "JPEG width must equal reported width × scale", - ); - assert.equal( - img.height, - Math.round(shot.height * shot.scale), - "JPEG height must equal reported height × scale", - ); + // Core contract: bitmap width/height equals reported width/height × scale. + assert.equal( + img.width, + Math.round(shot.width * shot.scale), + "JPEG width must equal reported width × scale", + ); + assert.equal( + img.height, + Math.round(shot.height * shot.scale), + "JPEG height must equal reported height × scale", + ); - // The reported width is the true CSS layout width (clientWidth at capture, - // excluding the scrollbar). This is the fix from pr-12 Branch B. - assert.equal( - shot.width, - probe.cw, - "reported width must be the page's CSS clientWidth", - ); + // The reported width is the true CSS layout width (clientWidth at capture, + // excluding the scrollbar). This is the fix from pr-12 Branch B. + assert.equal(shot.width, probe.cw, "reported width must be the page's CSS clientWidth"); - // Record zoom readings for the PR (soft assertions, not hard fails). - // zoom: innerWidth=${afterZoomIn.w} dpr=${afterZoomIn.d} vscale=${afterZoomIn.v} - // zoom-out: innerWidth=${afterZoomOut.w} dpr=${afterZoomOut.d} vscale=${afterZoomOut.v} - } finally { - await browsers.closeAll(); - } - }, -); + // Record zoom readings for the PR (soft assertions, not hard fails). + // zoom: innerWidth=${afterZoomIn.w} dpr=${afterZoomIn.d} vscale=${afterZoomIn.v} + // zoom-out: innerWidth=${afterZoomOut.w} dpr=${afterZoomOut.d} vscale=${afterZoomOut.v} + } finally { + await browsers.closeAll(); + } +}); diff --git a/test/session-row-ui.test.ts b/test/session-row-ui.test.ts index 26e7a0bb..99568c27 100644 --- a/test/session-row-ui.test.ts +++ b/test/session-row-ui.test.ts @@ -550,65 +550,59 @@ test( }, ); -test( - "a focused text field takes Escape before the session selection", - real, - async () => { - await fresh(); - // Open a session so the composer is rendered. - await page.click({ selector: `${row(active)} > .session` }); - await until( - async () => - await inPage( - `return JSON.stringify(document.querySelector(${JSON.stringify(".composer-wrap textarea")}) !== null)`, - ), - "the composer textarea", - ); - // Ctrl+click the open row to select it (it stays open, we just add it to the selection). - await tabTo(`${row(active)} > .session`); - // Native Control+Enter does not carry the modifier through headless clicks; - // dispatch a MouseEvent with ctrlKey as the reliable fallback. - await page.evaluate( - `document.querySelector(${JSON.stringify(row(active) + " > .session")}).dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }))`, - ); - await until( - async () => - await inPage( - `return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) !== null)`, - ), - "the select bar appears", - ); - // Focus the composer textarea, then press Escape. - await page.evaluate( - "document.querySelector('.composer-wrap textarea').focus();", - ); - // Verify textarea is focused - assert.ok( +test("a focused text field takes Escape before the session selection", real, async () => { + await fresh(); + // Open a session so the composer is rendered. + await page.click({ selector: `${row(active)} > .session` }); + await until( + async () => await inPage( - `return JSON.stringify(document.activeElement?.tagName === "TEXTAREA")`, + `return JSON.stringify(document.querySelector(${JSON.stringify(".composer-wrap textarea")}) !== null)`, ), - "textarea is focused before Escape", - ); - await page.press("Escape"); - // The select bar must still be present: the textarea took Escape. - assert.ok( + "the composer textarea", + ); + // Ctrl+click the open row to select it (it stays open, we just add it to the selection). + await tabTo(`${row(active)} > .session`); + // Native Control+Enter does not carry the modifier through headless clicks; + // dispatch a MouseEvent with ctrlKey as the reliable fallback. + await page.evaluate( + `document.querySelector(${JSON.stringify(row(active) + " > .session")}).dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }))`, + ); + await until( + async () => await inPage( `return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) !== null)`, ), - "select bar stays while a text field is focused", - ); - // Blur the field, then press Escape again: the selection clears. - await page.evaluate("document.activeElement?.blur();"); - await page.press("Escape"); - await until( - async () => - await inPage( - `return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) === null)`, - ), - "the select bar disappears after blur + Escape", - ); - }, -); + "the select bar appears", + ); + // Focus the composer textarea, then press Escape. + await page.evaluate("document.querySelector('.composer-wrap textarea').focus();"); + // Verify textarea is focused + assert.ok( + await inPage( + `return JSON.stringify(document.activeElement?.tagName === "TEXTAREA")`, + ), + "textarea is focused before Escape", + ); + await page.press("Escape"); + // The select bar must still be present: the textarea took Escape. + assert.ok( + await inPage( + `return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) !== null)`, + ), + "select bar stays while a text field is focused", + ); + // Blur the field, then press Escape again: the selection clears. + await page.evaluate("document.activeElement?.blur();"); + await page.press("Escape"); + await until( + async () => + await inPage( + `return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) === null)`, + ), + "the select bar disappears after blur + Escape", + ); +}); type Touched = { sheet: string | null; From e57d188d81ab5b4d522b0f99b080c8c42e581e5f Mon Sep 17 00:00:00 2001 From: T-Crypt Date: Tue, 13 Oct 2026 09:52:10 -0600 Subject: [PATCH 6/6] test(01): the temp folder's removal waits for the browser's last writes A browser closed just before the test's folder is removed can still be writing its profile, which left the folder non-empty and failed the run with ENOTEMPTY (run 38424667589, visible-models-ui). The removal now retries. --- test/helpers.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index 638f0fde..c3d90129 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -179,7 +179,11 @@ export function fakeTab(id: ConversationId | undefined, user: User) { return { client, events, last, field }; } -/** Remove the temporary folder: call from `after`, once the app is closed. */ +/** + * Remove the temporary folder: call from `after`, once the app is closed. A browser that has just been closed + * can still be writing its profile, which leaves the folder non-empty, so the removal retries: Node waits a + * little longer on each of these errors, and the folder is gone a moment later. + */ export function cleanUp(): void { - rmSync(root, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }