Skip to content

Commit 34bba4e

Browse files
committed
Add isolated shared UI browser test foundation
1 parent 5c11bf1 commit 34bba4e

14 files changed

Lines changed: 712 additions & 15 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ jobs:
3636
python-version: '3.13'
3737
- run: pnpm install --frozen-lockfile
3838
- run: pnpm check:public
39+
- name: Install Chromium for shared UI tests
40+
if: runner.os == 'Linux'
41+
run: pnpm --filter @stacktape/ui-react exec playwright install --with-deps chromium
42+
- name: Test shared UI in Chromium
43+
if: runner.os == 'Linux'
44+
run: pnpm --filter @stacktape/ui-react test:e2e
45+
- name: Retain synthetic browser failure artifacts
46+
if: failure() && runner.os == 'Linux'
47+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
48+
with:
49+
name: shared-ui-browser-failures
50+
path: .stacktape/test-runs/ui-react/
51+
include-hidden-files: true
52+
if-no-files-found: ignore
53+
retention-days: 7
3954
- name: Validate synthesized CloudFormation
4055
shell: bash
4156
run: |

‎apps/console‎

‎docs/testing/console.md‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,23 @@ covers
105105

106106
For automation, reuse the existing Developer or separately scoped Admin fixture described in
107107
[agent browser access](../../apps/console/e2e/README.md#agent-browser-access). The current
108-
[`signInBrowserUser`](../../apps/console/ui/e2e/sign-in.ts) helper loads credentials inside the Playwright worker and
109-
enters them through the real form, creating a fresh session for each test. Use the fixture matching the intended role;
110-
do not ask the owner to repeat login or create another identity before checking these fixtures.
111-
112-
There is no shared-dev authentication bypass in this setup. Reusing legitimately authenticated state could avoid
113-
repeated login steps, but that optimization still needs implementation and privacy qualification. Synthetic identities
114-
belong to the planned isolated harness, not the live dev API. Console login also does not authenticate
115-
GitHub/GitLab/Bitbucket provider sessions; follow the private guide for those callbacks and approvals.
108+
[browser fixtures](../../apps/console/ui/e2e/fixtures.ts) load credentials inside the Playwright worker and authenticate
109+
through the real form. Feature tests use `async ({ page, signIn })` and `await signIn()` (Developer), or
110+
`await signIn('admin')`. Each worker reuses a separate in-memory session for each identity and verified UI/API/auth
111+
target, for up to five minutes. Each test still has a fresh browser context; changed cookies, selected organizations and
112+
other test state never update the cached seed. Use the fixture matching the intended role; do not ask the owner to
113+
repeat login or create another identity before checking these fixtures.
114+
115+
Login, logout, revocation and session-expiry scenarios must use the fresh
116+
[`signInBrowserUser(page, identity)`](../../apps/console/ui/e2e/sign-in.ts) helper instead of cached sessions. A failed
117+
application request does not trigger a hidden login retry. Cached state is discarded when the worker ends and is never
118+
saved to disk. Run `pnpm --filter @stacktape/console-ui test:e2e:sessions` and `test:e2e:privacy` after changing these
119+
helpers; both qualifiers are offline.
120+
121+
There is no shared-dev authentication bypass. Synthetic identities belong to the planned isolated harness, not the live
122+
dev API. Shared-dev tests remain serialized under a reservation; session reuse does not isolate server-side data.
123+
Console login also does not authenticate GitHub/GitLab/Bitbucket provider sessions; follow the private guide for those
124+
callbacks and approvals.
116125

117126
For a UI-only change, one command starts the current UI, waits for it, runs authenticated Chromium navigation against
118127
the deployed dev API, and stops the UI afterward. It refuses to reuse an existing server:

‎docs/testing/e2e.md‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ providers where needed, and explicit fixture data. This lets agents inspect exac
103103
galleries out of production artifacts. For complete journeys, run the actual app; avoid replacing its entrypoint or
104104
intercepting its own API. Existing Console page fixtures are examples of the first category, not the second.
105105

106+
The public shared-control gallery is available now: `pnpm --filter @stacktape/ui-react test:e2e` runs Chromium against
107+
real components and CSS; `pnpm --filter @stacktape/ui-react dev:e2e` opens the same synthetic gallery for inspection.
108+
Add scenarios under [`packages/ui-react/e2e`](../../packages/ui-react/e2e), importing its `test` fixture. It owns a
109+
loopback Vite server and temporary cache per worker, fresh contexts per test and external-traffic rejection. It does not
110+
need Console or AWS. Use `--repeat-each=3 --workers=1` to check repeatability, then the default two workers to check
111+
independent execution. Public CI runs this lane on Linux and retains synthetic failure artifacts for seven days. The
112+
normal public gate does not require an installed browser.
113+
106114
Test behavior that static markup cannot establish: keyboard navigation, focus after opening/closing, disabled controls,
107115
actual input/change events, portals, editor diagnostics and persistence. Use a small number of screenshot comparisons
108116
only where appearance is the contract. Stabilize fonts, viewport, time and animation; review visual baselines
@@ -203,12 +211,13 @@ written plan for every scenario. Use standard failure artifacts when they help r
203211

204212
Real Console credentials need the existing private browser procedure: SSM-backed worker-only loading, no secrets in
205213
shell arguments or Vite, disabled authenticated traces/screenshots/video, and suppression of credential-bearing failure
206-
snapshots. Auth state files also contain secrets; use memory when practical and otherwise ignored, restricted files with
207-
explicit deletion. Loading storage state does not make later HTTP traces safe: authenticated requests still carry
208-
tokens. Before changing these settings or upgrading browser tools, run
209-
`pnpm --filter @stacktape/console-ui test:e2e:privacy`. A new agent CLI must be qualified separately before giving it
210-
real credential-bearing flows; the runner's privacy test does not automatically cover another tool.
211-
[Private browser procedure](../../apps/console/e2e/README.md#agent-browser-access).
214+
snapshots. The current Console `signIn` fixture caches authenticated state in worker memory, separated by identity and
215+
verified target, with fresh test contexts and a five-minute maximum seed age. Keep login/logout and revocation tests on
216+
the fresh-login helper. Auth state files also contain secrets; do not save these Console sessions to disk. Loading
217+
storage state does not make later HTTP traces safe: authenticated requests still carry tokens. Before changing these
218+
settings or upgrading browser tools, run `pnpm --filter @stacktape/console-ui test:e2e:privacy`. A new agent CLI must be
219+
qualified separately before giving it real credential-bearing flows; the runner's privacy test does not automatically
220+
cover another tool. [Private browser procedure](../../apps/console/e2e/README.md#agent-browser-access).
212221

213222
For ordinary integration failures, keep the scenario name, failing assertion and bounded last process/job state. Log
214223
only sanitized provider request shape and stable operation IDs. A retry that never reached the consumer is not a

‎knip.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@
5858
"packages/*": {
5959
"entry": ["src/**/*.{ts,tsx}", "scripts/**/*.ts"]
6060
},
61+
"packages/ui-react": {
62+
"entry": ["src/**/*.{ts,tsx}", "scripts/**/*.ts"],
63+
"project": ["**/*.{ts,tsx}", "!e2e/**!"],
64+
"vite": false
65+
},
6166
"packages/config": {
6267
"entry": ["src/**/*.{ts,tsx}", "tests/*.acceptance.ts"]
6368
},

‎packages/ui-react/e2e/fixtures.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { test as base } from '@playwright/test';
5+
import { createServer, type ViteDevServer } from 'vite';
6+
import galleryConfig from '../vite.e2e.config.ts';
7+
8+
export const test = base.extend<{ networkBoundary: void }, { galleryURL: string }>({
9+
galleryURL: [
10+
// Playwright requires a destructured first argument even when this fixture has no dependencies.
11+
// oxlint-disable-next-line no-empty-pattern
12+
async ({}, use) => {
13+
const cacheDir = await mkdtemp(join(tmpdir(), 'stacktape-ui-gallery-'));
14+
let server: ViteDevServer | undefined;
15+
try {
16+
server = await createServer({ ...galleryConfig, configFile: false, cacheDir });
17+
await server.listen();
18+
const address = server.httpServer?.address();
19+
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
20+
throw new Error('The gallery must own a loopback HTTP listener.');
21+
}
22+
const url = `http://127.0.0.1:${address.port}`;
23+
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
24+
if (!response.ok || !(await response.text()).includes('Shared UI gallery')) {
25+
throw new Error('The gallery did not serve its expected entrypoint.');
26+
}
27+
await use(url);
28+
} finally {
29+
try {
30+
await server?.close();
31+
} finally {
32+
await rm(cacheDir, { recursive: true, force: true });
33+
}
34+
}
35+
},
36+
{ scope: 'worker', timeout: 30_000 }
37+
],
38+
baseURL: async ({ galleryURL }, use) => use(galleryURL),
39+
networkBoundary: [
40+
async ({ context, galleryURL }, use) => {
41+
const unexpectedOrigins = new Set<string>();
42+
await context.route('**/*', async (route) => {
43+
const origin = new URL(route.request().url()).origin;
44+
if (origin === galleryURL) {
45+
await route.continue();
46+
} else {
47+
unexpectedOrigins.add(origin);
48+
await route.abort('blockedbyclient');
49+
}
50+
});
51+
await context.routeWebSocket('**/*', (socket) => {
52+
const origin = new URL(socket.url()).origin;
53+
if (origin === galleryURL.replace('http:', 'ws:')) {
54+
socket.connectToServer();
55+
} else {
56+
unexpectedOrigins.add(origin);
57+
socket.close();
58+
}
59+
});
60+
await use();
61+
if (unexpectedOrigins.size) {
62+
throw new Error(`The synthetic gallery attempted external traffic: ${[...unexpectedOrigins].join(', ')}`);
63+
}
64+
},
65+
{ auto: true }
66+
]
67+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect } from '@playwright/test';
2+
import { test } from './fixtures.ts';
3+
4+
test('the gallery serves an interactive shared Dialog', async ({ page }) => {
5+
await page.goto('/');
6+
expect(await page.evaluate(() => localStorage.getItem('gallery-smoke'))).toBeNull();
7+
await page.evaluate(() => localStorage.setItem('gallery-smoke', 'dialog case'));
8+
9+
await page.getByRole('button', { name: 'Open example dialog' }).click();
10+
await expect(page.getByRole('dialog', { name: 'Example dialog' })).toBeVisible();
11+
});
12+
13+
test('an independent gallery case starts with fresh browser state', async ({ page }) => {
14+
await page.goto('/');
15+
await expect(page.getByRole('heading', { name: 'Shared UI gallery' })).toBeVisible();
16+
expect(await page.evaluate(() => localStorage.getItem('gallery-smoke'))).toBeNull();
17+
await page.evaluate(() => localStorage.setItem('gallery-smoke', 'independent case'));
18+
await expect(page.getByRole('dialog')).toHaveCount(0);
19+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Shared UI gallery</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useState } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import { Button } from '../../src/button/Button.tsx';
4+
import { Dialog } from '../../src/dialog/Dialog.tsx';
5+
import '../../src/styles.css';
6+
7+
function Gallery() {
8+
const [open, setOpen] = useState(false);
9+
10+
return (
11+
<main>
12+
<h1>Shared UI gallery</h1>
13+
<Button variant="primary" onClick={() => setOpen(true)}>
14+
Open example dialog
15+
</Button>
16+
<Dialog
17+
open={open}
18+
onOpenChange={setOpen}
19+
title="Example dialog"
20+
actions={
21+
<Button variant="secondary" onClick={() => setOpen(false)}>
22+
Done
23+
</Button>
24+
}
25+
>
26+
<p>This dialog uses the shared component and stylesheet with synthetic content.</p>
27+
<label htmlFor="example-name">Example name</label>
28+
<input id="example-name" defaultValue="Example project" />
29+
<Button variant="primary" disabled>
30+
Unavailable action
31+
</Button>
32+
</Dialog>
33+
</main>
34+
);
35+
}
36+
37+
const root = document.getElementById('root');
38+
if (!root) throw new Error('The gallery root is missing.');
39+
createRoot(root).render(<Gallery />);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"include": ["./**/*.ts", "./**/*.tsx", "../playwright.config.ts", "../vite.e2e.config.ts"],
4+
"exclude": [],
5+
"compilerOptions": {
6+
"types": ["bun", "vite/client"]
7+
}
8+
}

0 commit comments

Comments
 (0)