Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches:
- main
- oh-my-pocket
pull_request:

permissions:
contents: read

jobs:
checks:
runs-on: ubuntu-latest
strategy:
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
with:
node-version: ${{ matrix.node }}
- run: npm ci
- 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
6 changes: 3 additions & 3 deletions .ome/project.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
144 changes: 68 additions & 76 deletions test/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, { url?: string }>();
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<number, { url?: string }>();
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();
}
});
8 changes: 6 additions & 2 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
102 changes: 48 additions & 54 deletions test/session-row-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(
`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<boolean>(
`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<boolean>(
`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<boolean>(
`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<boolean>(
`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<boolean>(
`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<boolean>(
`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<boolean>(
`return JSON.stringify(document.querySelector(${JSON.stringify(".select-bar")}) === null)`,
),
"the select bar disappears after blur + Escape",
);
});

type Touched = {
sheet: string | null;
Expand Down
4 changes: 2 additions & 2 deletions test/visible-models-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -136,7 +136,7 @@ async function menu(): Promise<string[]> {
"the model menu",
);
const rows = await inPage<string[]>(
`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");
Expand Down
Loading