Skip to content

Commit b6fed4a

Browse files
ci(e2e): serve hub-client via vite preview instead of vite dev
Cuts wall time and removes flakes by replacing the dev-mode hub-client server with a prebuilt bundle served through `vite preview`. The win is mostly mechanical: a cold Playwright context downloads ~50 MB of assets (WASM dominates at 32 MB). Through `vite dev` that goes uncompressed and serializes through a single-threaded transform pipeline; through `vite preview` with a gzip middleware it's ~5.6 MB on the wire and served as static files. Under 2 Playwright workers on a 2-core CI runner this was the source of the "preview iframe didn't render in 45s" flakes. Local smoke-all: 1.9 min / 1 flake → 1.1 min / 0 flakes (42% faster). Full functional suite: 72 passed, 0 failed, 3 flaky (recovered), 2.6 min. Major pieces - hub-client/vite.config.ts: mirror `server.proxy` into `preview.proxy` (preview ignores `server.proxy`). Add a small `configurePreviewServer` plugin that runs `compression()` middleware; override its filter so `application/wasm` is included (mime-db marks it non-compressible by default — in practice it gzips ~6:1). - hub-client/playwright.config.ts: `webServer.command` → `vite preview`; comment why it's not `vite dev` so the next agent doesn't "helpfully" revert it for HMR. - hub-client/ast-renderer.html: moved from `public/` to project root. When it lived in `public/` the build emitted two copies — the transformed one at `dist/public/ast-renderer.html` and a raw copy at `dist/ast-renderer.html` (with a dev-only `<script src="/src/...tsx">` reference). The iframe `src="/ast-renderer.html"` hit the raw one in preview mode and the q2-debug E2E test broke. This was a latent prod bug — `vite dev` happened to mask it via source-path resolution. Test-hook plumbing - src/test-hooks.ts (new): registers `window.__quartoTest = { projectStorage, wasmRenderer }`. Tree-shaken out of any build without `VITE_E2E=1`. - src/main.tsx: kicks off `import('./test-hooks')` and stores the promise on `window.__quartoTestReady`. Top-level `await` here doesn't help (the `load` event fires before module top-level awaits resolve), so tests `await window.__quartoTestReady` before reading the hooks. - e2e/helpers/testHooks.ts (new): typed global augmentation. - e2e/helpers/previewExtraction.ts, projectFactory.ts, share-link-project-set.spec.ts: 5 `await import('/src/services/...ts')` call sites replaced with `await window.__quartoTestReady` + `window.__quartoTest.{projectStorage,wasmRenderer}`. The dev-only source-path imports stopped working under `vite preview` (prod bundles don't expose source paths). - package.json: `test:e2e[:ui]` scripts now set `VITE_E2E=1` and build before running playwright (since preview serves from `dist/`). - .github/workflows/hub-client-e2e.yml: `VITE_E2E: '1'` on the Build TypeScript packages step. Side effects worth knowing - Running `npx playwright test` directly (bypassing `npm run test:e2e`) serves whatever was last built into `dist/` — possibly stale. The npm-script path always rebuilds. - New devDeps `compression` + `@types/compression` (83 transitive packages, dev-only, no shipped code). No production runtime change. - `vite dev`, `vite build` defaults, and the production user bundle are untouched: the compression plugin only fires under `vite preview`, and test-hooks is dead-code-eliminated without `VITE_E2E=1`.
1 parent b991a32 commit b6fed4a

12 files changed

Lines changed: 392 additions & 36 deletions

.github/workflows/hub-client-e2e.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,16 @@ jobs:
105105
# q2-demos/* currently fail their vite build (they import an
106106
# absolute path "/src/wasm-js-bridge/cache.js" that only exists
107107
# in hub-client/), and trace-viewer is not under test here.
108+
#
109+
# VITE_E2E=1 on the hub-client build pulls in src/test-hooks.ts so
110+
# the Playwright suite can reach internal services via
111+
# `window.__quartoTest` (production bundles don't expose source
112+
# paths the way `vite dev` did). The flag is consumed only by the
113+
# `if (import.meta.env.VITE_E2E === '1')` branch in src/main.tsx;
114+
# production-user builds leave it unset and tree-shake the hooks out.
108115
- name: Build TypeScript packages (ts-packages + hub-client)
116+
env:
117+
VITE_E2E: '1'
109118
run: |
110119
for pkg in ts-packages/*/; do
111120
if [ -f "$pkg/package.json" ]; then

hub-client/e2e/helpers/previewExtraction.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { Page } from '@playwright/test';
9+
import type {} from './testHooks';
910

1011
/**
1112
* Wait for the preview iframe to render content.
@@ -101,8 +102,10 @@ export async function getPreviewHtml(
101102
documentPath: string,
102103
): Promise<string> {
103104
return page.evaluate(async (docPath) => {
104-
const renderer = await import('/src/services/wasmRenderer.ts');
105-
const result = await renderer.renderToHtml({ documentPath: docPath });
105+
await window.__quartoTestReady;
106+
const hooks = window.__quartoTest;
107+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
108+
const result = await hooks.wasmRenderer.renderToHtml({ documentPath: docPath });
106109
return result.html ?? '';
107110
}, documentPath);
108111
}
@@ -121,7 +124,10 @@ export async function getPreviewCss(page: Page): Promise<string> {
121124
throw new Error('No active preview iframe found');
122125
}
123126

124-
const renderer = await import('/src/services/wasmRenderer.ts');
127+
await window.__quartoTestReady;
128+
const hooks = window.__quartoTest;
129+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
130+
const renderer = hooks.wasmRenderer;
125131
const links = iframe.contentDocument.querySelectorAll('link[rel="stylesheet"]');
126132
let combinedCss = '';
127133

@@ -187,8 +193,10 @@ export async function getRenderDiagnostics(
187193
warnings: RenderDiagnostic[];
188194
}> {
189195
return page.evaluate(async (docPath) => {
190-
const renderer = await import('/src/services/wasmRenderer.ts');
191-
const result = await renderer.renderToHtml({ documentPath: docPath });
196+
await window.__quartoTestReady;
197+
const hooks = window.__quartoTest;
198+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
199+
const result = await hooks.wasmRenderer.renderToHtml({ documentPath: docPath });
192200
return {
193201
success: result.success,
194202
error: result.error,

hub-client/e2e/helpers/projectFactory.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { SERVER_INFO_PATH } from './globalSetup';
2020
import type { ServerInfo } from './globalSetup';
2121
import { expect, type Page } from '@playwright/test';
22+
import type {} from './testHooks';
2223

2324
export interface ProjectFile {
2425
path: string;
@@ -160,8 +161,10 @@ export async function seedProjectInBrowser(
160161
): Promise<string> {
161162
return page.evaluate(
162163
async ({ indexDocId, syncServer, name }) => {
163-
const ps = await import('/src/services/projectStorage.ts');
164-
const entry = await ps.addProject(indexDocId, syncServer, name);
164+
await window.__quartoTestReady;
165+
const hooks = window.__quartoTest;
166+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
167+
const entry = await hooks.projectStorage.addProject(indexDocId, syncServer, name);
165168
return entry.id;
166169
},
167170
{ indexDocId, syncServer, name },
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Type declarations for the in-browser test hooks installed by
3+
* `src/test-hooks.ts`. The hooks are registered on `window.__quartoTest`
4+
* only when the bundle is built with `VITE_E2E=1` (see playwright.config.ts).
5+
*
6+
* This file is for types only — the runtime accessor lives inside the
7+
* `page.evaluate(...)` callbacks because page.evaluate ships callback
8+
* source to the browser, where Node-side helpers aren't reachable.
9+
*
10+
* Always `await window.__quartoTestReady` before reading `window.__quartoTest`
11+
* — the hooks module loads asynchronously and the page's `load` event fires
12+
* before module top-level awaits resolve.
13+
*
14+
* Usage from a test/helper:
15+
*
16+
* ```ts
17+
* import type {} from './testHooks'; // ambient global augmentation
18+
*
19+
* await page.evaluate(async () => {
20+
* await window.__quartoTestReady;
21+
* const hooks = window.__quartoTest!;
22+
* return hooks.projectStorage.addProject(...);
23+
* });
24+
* ```
25+
*/
26+
import type * as projectStorage from '../../src/services/projectStorage';
27+
import type * as wasmRenderer from '../../src/services/wasmRenderer';
28+
29+
export interface QuartoTestHooks {
30+
projectStorage: typeof projectStorage;
31+
wasmRenderer: typeof wasmRenderer;
32+
}
33+
34+
declare global {
35+
interface Window {
36+
__quartoTest?: QuartoTestHooks;
37+
__quartoTestReady?: Promise<unknown>;
38+
}
39+
}

hub-client/e2e/share-link-project-set.spec.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
createProjectOnServer,
2222
getServerUrl,
2323
} from './helpers/projectFactory';
24+
import type {} from './helpers/testHooks';
2425

2526
/**
2627
* Bootstrap the receiver browser with an existing synced project set.
@@ -201,8 +202,10 @@ test.describe('Share link → synced project set', () => {
201202
.poll(
202203
() =>
203204
sharePage.evaluate(async (docId) => {
204-
const ps = await import('/src/services/projectStorage.ts');
205-
const entry = await ps.getProjectByIndexDocId(`automerge:${docId}`);
205+
await window.__quartoTestReady;
206+
const hooks = window.__quartoTest;
207+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
208+
const entry = await hooks.projectStorage.getProjectByIndexDocId(`automerge:${docId}`);
206209
return !!entry;
207210
}, sharedIndexDocId),
208211
{ timeout: 15000 },
@@ -268,8 +271,10 @@ test.describe('Share link → synced project set', () => {
268271
// nothing about — this is exactly the state Bug A produces.
269272
await bootstrapPage.evaluate(
270273
async ({ indexDocId, server }) => {
271-
const ps = await import('/src/services/projectStorage.ts');
272-
await ps.addProject(`automerge:${indexDocId}`, server, 'Orphan Demo');
274+
await window.__quartoTestReady;
275+
const hooks = window.__quartoTest;
276+
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
277+
await hooks.projectStorage.addProject(`automerge:${indexDocId}`, server, 'Orphan Demo');
273278
},
274279
{ indexDocId: orphanIndexDocId, server: syncServer },
275280
);

hub-client/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
"test:integration:watch": "vitest --config vitest.integration.config.ts",
2121
"test:wasm": "vitest run --config vitest.wasm.config.ts",
2222
"test:wasm:watch": "vitest --config vitest.wasm.config.ts",
23-
"test:e2e": "npm run build:wasm && playwright test",
24-
"test:e2e:ui": "npm run build:wasm && playwright test --ui",
23+
"test:e2e": "npm run build:wasm && VITE_E2E=1 npm run build && playwright test",
24+
"test:e2e:ui": "npm run build:wasm && VITE_E2E=1 npm run build && playwright test --ui",
2525
"test:visual": "playwright test --config playwright.visual.config.ts",
2626
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots=missing",
2727
"test:ci": "npm run test && npm run test:integration && npm run test:wasm",
@@ -56,13 +56,15 @@
5656
"@playwright/test": "^1.50.0",
5757
"@testing-library/jest-dom": "^6.6.3",
5858
"@testing-library/react": "^16.1.0",
59+
"@types/compression": "^1.8.1",
5960
"@types/ejs": "^3.1.5",
6061
"@types/node": "^24.10.1",
6162
"@types/react": "^19.2.5",
6263
"@types/react-dom": "^19.2.3",
6364
"@types/ws": "^8.18.1",
6465
"@vitejs/plugin-react": "^5.1.1",
6566
"@vitest/coverage-v8": "^4.0.17",
67+
"compression": "^1.8.1",
6668
"eslint": "^9.39.1",
6769
"eslint-plugin-react-hooks": "^7.0.1",
6870
"eslint-plugin-react-refresh": "^0.4.24",

hub-client/playwright.config.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,32 @@ export default defineConfig({
6060
// },
6161
],
6262

63-
// Run local dev server before tests
63+
// Serve the prebuilt hub-client bundle via `vite preview` rather than
64+
// `vite dev`. Why this matters for E2E throughput:
65+
// - A cold Playwright browser context fetches ~50 MB of assets
66+
// (the ~32 MB WASM dominates). Through `vite dev` that goes
67+
// uncompressed and serialized through the transform pipeline;
68+
// through `vite preview` (with our gzip middleware in vite.config.ts)
69+
// it's ~5.6 MB on the wire and served as static files.
70+
// - `vite dev` also re-transforms ~500 TS/JSX modules per page load;
71+
// the production bundle is ~10 chunks.
72+
// - Under 2 Playwright workers contending for one dev server on a
73+
// 2-core CI runner this was the source of the "preview iframe
74+
// didn't render in 45s" flakes. Do NOT revert this to `npm run dev`
75+
// to get HMR back — HMR isn't used by tests and dev mode reintroduces
76+
// the contention.
6477
webServer: {
65-
command: 'npm run dev',
78+
command: 'npm run preview -- --port 5173',
6679
url: 'http://localhost:5173',
6780
// Reuse existing server in dev mode for faster iteration
6881
reuseExistingServer: !process.env.CI,
69-
// Timeout for server to start
82+
// Timeout for server to start (preview is near-instant; the bundle
83+
// must already be built, which the CI workflow does explicitly).
7084
timeout: 120000,
71-
// Vite proxies /auth/* and the websocket to VITE_HUB_SERVER
72-
// (default http://localhost:3000). globalSetup starts the e2e hub
73-
// on port 3030, so point Vite at that port.
85+
// Vite preview reads VITE_HUB_SERVER at config-eval time to wire
86+
// preview.proxy at the target. globalSetup starts the e2e hub on
87+
// port 3030, so point preview at that port. The env is server-side
88+
// only — it isn't baked into the built client bundle.
7489
env: {
7590
VITE_HUB_SERVER: 'http://localhost:3030',
7691
},

hub-client/src/main.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ import { savePreAuthHash, restorePreAuthHash } from './utils/routing'
1212
// Order matters: restore first (consumes saved value), then save current.
1313
restorePreAuthHash() || savePreAuthHash();
1414

15+
// E2E test hooks. Tree-shaken out unless VITE_E2E=1 was set at build time.
16+
// We expose the dynamic-import promise on `window.__quartoTestReady` so
17+
// `page.evaluate` callbacks can `await` it. Top-level await here would
18+
// not help: it blocks React mount but the browser's `load` event (which
19+
// playwright `goto` waits for) fires before module top-level awaits
20+
// resolve, so a naive `window.__quartoTest` access can still race.
21+
if (import.meta.env.VITE_E2E === '1') {
22+
(window as Window & { __quartoTestReady?: Promise<unknown> }).__quartoTestReady =
23+
import('./test-hooks');
24+
}
25+
1526
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
1627

1728
const root = (

hub-client/src/test-hooks.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* E2E test hooks: expose a couple of internal services on `window` so the
3+
* Playwright suite can bypass UI to seed projects (`projectStorage`) and
4+
* render qmd directly (`wasmRenderer`).
5+
*
6+
* Why this exists: the E2E suite used to reach into the source tree via
7+
* `await import('/src/services/...ts')`, which only works under `vite dev`.
8+
* The CI run uses `vite preview` (production bundle) for throughput; this
9+
* module is the bridge so the same tests work against the prod bundle.
10+
*
11+
* Inclusion is gated on `import.meta.env.VITE_E2E === '1'` at build time
12+
* (see `src/main.tsx`). Without that flag, vite tree-shakes this module
13+
* out of the production bundle entirely.
14+
*/
15+
import * as projectStorage from './services/projectStorage';
16+
import * as wasmRenderer from './services/wasmRenderer';
17+
18+
declare global {
19+
interface Window {
20+
__quartoTest?: {
21+
projectStorage: typeof projectStorage;
22+
wasmRenderer: typeof wasmRenderer;
23+
};
24+
}
25+
}
26+
27+
window.__quartoTest = { projectStorage, wasmRenderer };

0 commit comments

Comments
 (0)