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
52 changes: 16 additions & 36 deletions .github/workflows/hub-client-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,24 @@ jobs:
chmod +x tree-sitter-linux-x86
sudo mv tree-sitter-linux-x86 /usr/local/bin/tree-sitter

# Node.js and dependencies
# Node.js and dependencies.
#
# PINNED to 24.15.0 — do NOT bump to a bare '24' (which floats to
# >=24.16). Node 24.16.0 introduced a yauzl stream-destruction
# regression that hangs `for await` over `openReadStream`, which
# makes `playwright install chromium` hang FOREVER right after the
# browser download hits 100% (extraction deadlocks; the job then
# burns the full 6h cap). It is fixed in Playwright >= 1.60.0, but
# we pin @playwright/test at ^1.50.0 -> 1.58.0. 24.15.0 is the last
# Node release before the regression, so it keeps Playwright 1.58 /
# Chromium 145 and leaves the visual-regression baselines unchanged.
# Refs: microsoft/playwright#40724, fixed by #40747 (PW 1.60.0).
# REMOVE this pin once @playwright/test is bumped to >= 1.60.0 (and
# regenerate the visual baselines for the newer Chromium then).
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
node-version: '24.15.0'
cache: 'npm'

- name: Install npm dependencies
Expand Down Expand Up @@ -127,41 +140,8 @@ jobs:
- name: Pre-build hub binary
run: cargo build --bin hub

# Cache the downloaded browser binaries across runs. Keyed on the
# lockfile so a Playwright version bump invalidates the cache and
# re-downloads; otherwise we skip the 175 MB chromium download
# entirely.
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# Install Playwright browsers.
#
# The full Chromium (Chrome for Testing) build is served from a
# SINGLE mirror with no fallback: cdn.playwright.dev/chrome-for-
# testing-public (Playwright's cftUrl() helper). cdn.playwright.dev
# is fronted by Azure Front Door, and from the (also-Azure) GitHub
# runners that front door sends all ~167 MiB and then holds the
# connection open without EOF — so Playwright's download promise
# never resolves and the step hangs until the 6h job cap. With no
# fallback mirror for Chrome for Testing, Playwright can't recover.
# (Carlos report, 2026-05; confirmed from the 100%-then-hang in the
# run-26760896905 log.)
#
# Fix: point the chromium download host at the GCS origin that
# cdn.playwright.dev merely redirects to. That origin serves the zip
# with a correct content-length and a clean EOF. On linux-x64 BOTH
# the full Chromium and the headless-shell resolve via cftUrl(), so
# this one var fixes both; ffmpeg's name isn't "chromium*" so the
# override doesn't touch it and it keeps its 3-mirror fallback.
# PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT is a belt-and-suspenders so
# any future post-100% stall aborts in 2 min instead of hanging.
# Install Playwright browsers
- name: Install Playwright
env:
PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST: https://storage.googleapis.com/chrome-for-testing-public
PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT: '120000'
run: |
cd hub-client
npx playwright install --with-deps chromium
Expand Down
39 changes: 39 additions & 0 deletions hub-client/e2e/helpers/projectFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ export async function bootstrapProjectSet(
* synced project set is initialized; otherwise the App lands on the
* needs-migration screen.
*
* Before returning, this waits until the seeded project is actually present
* in the *connected, synced* project set — not just written to IDB. That
* closes the race behind the smoke-all flakiness (bd-3nzyd): `addProject`
* only writes IDB, and the app reconciles IDB→set on the status→connected
* transition, which does NOT re-fire for a project seeded *after* the set is
* already connected. So we wait for the real peer connection, run the
* idempotent reconciler ourselves, and wait for the project to appear. The
* full Automerge sync path stays exercised end-to-end — the test just stops
* racing it. Waits are bounded so a genuinely unreachable sync server fails
* loudly here instead of surfacing 75s later as a preview-render timeout.
*
* Returns the local project ID (UUID) used in URL navigation.
*/
export async function seedProjectInBrowser(
Expand All @@ -175,6 +186,34 @@ export async function seedProjectInBrowser(
const hooks = window.__quartoTest;
if (!hooks) throw new Error('__quartoTest missing — rebuild with VITE_E2E=1');
const entry = await hooks.projectStorage.addProject(indexDocId, syncServer, name);

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deadline = Date.now() + 30000;

// 1) Wait for the real project-set peer connection (the app's implicit
// 5s waitForPeer is too tight in CI; give it a generous window).
while (!hooks.projectSet.isConnected() && Date.now() < deadline) {
await sleep(100);
}
if (!hooks.projectSet.isConnected()) {
throw new Error(
'Project set did not reach connected state within 30s — sync server unreachable?',
);
}

// 2) Land the seeded IDB entry into the synced set (idempotent), then
// wait until it is observably present before the caller navigates.
while (!hooks.projectSet.getProject(indexDocId) && Date.now() < deadline) {
await hooks.reconcileProjectSet();
if (hooks.projectSet.getProject(indexDocId)) break;
await sleep(100);
}
if (!hooks.projectSet.getProject(indexDocId)) {
throw new Error(
`Seeded project ${indexDocId} never appeared in the connected project set within 30s`,
);
}

return entry.id;
},
{ indexDocId, syncServer, name },
Expand Down
4 changes: 4 additions & 0 deletions hub-client/e2e/helpers/testHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@
* ```
*/
import type * as projectStorage from '../../src/services/projectStorage';
import type * as projectSet from '../../src/services/projectSetService';
import type { reconcileIntoConnectedProjectSet } from '../../src/services/projectSetReconciler';
import type * as wasmRenderer from '../../src/services/wasmRenderer';

export interface QuartoTestHooks {
projectStorage: typeof projectStorage;
projectSet: typeof projectSet;
reconcileProjectSet: typeof reconcileIntoConnectedProjectSet;
wasmRenderer: typeof wasmRenderer;
}

Expand Down
19 changes: 18 additions & 1 deletion hub-client/src/test-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,32 @@
* out of the production bundle entirely.
*/
import * as projectStorage from './services/projectStorage';
import * as projectSet from './services/projectSetService';
import { reconcileIntoConnectedProjectSet } from './services/projectSetReconciler';
import * as wasmRenderer from '@quarto/preview-runtime';

declare global {
interface Window {
__quartoTest?: {
projectStorage: typeof projectStorage;
// The live project-set service singleton (same instance the app uses),
// so the E2E suite can observe real connection/sync state — e.g. wait
// for `isConnected()` and `getProject(indexDocId)` after seeding before
// navigating, instead of racing the implicit reconcile-on-connect.
projectSet: typeof projectSet;
// Idempotent IDB→synced-set reconciler. The app runs this only on the
// status→connected transition, which does not re-fire for a project
// seeded after the set is already connected; the suite invokes it
// explicitly so a seeded project deterministically lands in the set.
reconcileProjectSet: typeof reconcileIntoConnectedProjectSet;
wasmRenderer: typeof wasmRenderer;
};
}
}

window.__quartoTest = { projectStorage, wasmRenderer };
window.__quartoTest = {
projectStorage,
projectSet,
reconcileProjectSet: reconcileIntoConnectedProjectSet,
wasmRenderer,
};
Loading