Skip to content

Commit b084ab0

Browse files
authored
fix(release): allow Chromium sandbox in prerelease E2E (#7090)
* fix(release): allow Chromium sandbox in prerelease E2E * fix(web): cancel stale preview recovery probes
1 parent b800b91 commit b084ab0

4 files changed

Lines changed: 196 additions & 6 deletions

File tree

.github/workflows/release-prerelease.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,17 @@ jobs:
247247
run: pnpm install --frozen-lockfile
248248
- name: Setup Playwright
249249
run: pnpm -C e2e exec playwright install --with-deps chromium
250+
# Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor.
251+
# The daemon launches the installed Chromium directly instead of through
252+
# Playwright. Restore user namespaces on this ephemeral runner so the real
253+
# Chromium sandbox remains enabled rather than disabling that protection.
254+
- name: Allow Chromium user namespaces on GitHub Ubuntu
255+
shell: bash
256+
run: |
257+
set -euo pipefail
258+
if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
259+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
260+
fi
250261
- name: Prebuild workspace type declarations
251262
run: |
252263
pnpm --filter @open-design/daemon build

apps/web/src/components/FileViewer.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10234,10 +10234,33 @@ function HtmlViewer({
1023410234
probeId: string;
1023510235
recoverOnFailure: boolean;
1023610236
} | null>(null);
10237+
const srcDocTransportTimeoutsRef = useRef<Set<number>>(new Set());
1023710238
const srcDocParsingGraceRef = useRef<{
1023810239
generation: string;
1023910240
deadline: number;
1024010241
} | null>(null);
10242+
const clearSrcDocTransportTimeouts = useCallback(() => {
10243+
for (const timeout of srcDocTransportTimeoutsRef.current) {
10244+
window.clearTimeout(timeout);
10245+
}
10246+
srcDocTransportTimeoutsRef.current.clear();
10247+
}, []);
10248+
const scheduleSrcDocTransportTimeout = useCallback((callback: () => void, delay: number) => {
10249+
const timeout = window.setTimeout(() => {
10250+
srcDocTransportTimeoutsRef.current.delete(timeout);
10251+
callback();
10252+
}, delay);
10253+
srcDocTransportTimeoutsRef.current.add(timeout);
10254+
}, []);
10255+
const cancelPendingSrcDocTransport = useCallback(() => {
10256+
clearSrcDocTransportTimeouts();
10257+
pendingSrcDocTransportProbeRef.current = null;
10258+
srcDocParsingGraceRef.current = null;
10259+
}, [clearSrcDocTransportTimeouts]);
10260+
useEffect(() => {
10261+
if (!workspaceActive || mode !== 'preview') cancelPendingSrcDocTransport();
10262+
return cancelPendingSrcDocTransport;
10263+
}, [cancelPendingSrcDocTransport, mode, srcDocTransportGeneration, workspaceActive]);
1024110264
const replayPreviewBridgeModes = useCallback((target: HTMLIFrameElement | null) => {
1024210265
if (!workspaceActive) return;
1024310266
const win = target?.contentWindow;
@@ -10355,6 +10378,7 @@ function HtmlViewer({
1035510378
if (srcDocRecoveryAttemptedGenerationRef.current === generation) return;
1035610379
srcDocRecoveryAttemptedGenerationRef.current = generation;
1035710380
const ready = readySrcDocTransportRef.current;
10381+
cancelPendingSrcDocTransport();
1035810382
reportPreviewTransportRecovery({
1035910383
surface: 'artifact_preview',
1036010384
renderMode: 'srcdoc',
@@ -10371,15 +10395,13 @@ function HtmlViewer({
1037110395
viewportHeight: frame?.clientHeight,
1037210396
timeoutMs: signal === 'probe_timeout' ? SRC_DOC_READY_PROBE_TIMEOUT_MS : undefined,
1037310397
});
10374-
pendingSrcDocTransportProbeRef.current = null;
10375-
srcDocParsingGraceRef.current = null;
1037610398
verifiedSrcDocTransportRef.current = null;
1037710399
readySrcDocTransportRef.current = null;
1037810400
activatedSrcDocTransportHtmlRef.current = null;
1037910401
setSrcDocShellReady(false);
1038010402
setSrcDocRecoveryGeneration(generation);
1038110403
setSrcDocTransportResetKey((key) => key + 1);
10382-
}, [file.kind, file.name, handoffArtifactKind, projectId]);
10404+
}, [cancelPendingSrcDocTransport, file.kind, file.name, handoffArtifactKind, projectId]);
1038310405
const probeSrcDocTransport = useCallback((
1038410406
generation: string,
1038510407
recoverOnFailure: boolean,
@@ -10401,6 +10423,7 @@ function HtmlViewer({
1040110423
// Recovery probes can be shared by the timer and onLoad paths. A passive
1040210424
// prewarm probe may target the lazy shell, so the real srcDoc must replace it.
1040310425
if (pendingRecoveryProbeMatches) return;
10426+
clearSrcDocTransportTimeouts();
1040410427
srcDocTransportProbeSequenceRef.current += 1;
1040510428
const probeId = `${generation}:probe-${srcDocTransportProbeSequenceRef.current}`;
1040610429
pendingSrcDocTransportProbeRef.current = {
@@ -10420,7 +10443,7 @@ function HtmlViewer({
1042010443
generation,
1042110444
probeId,
1042210445
}, '*');
10423-
window.setTimeout(() => {
10446+
scheduleSrcDocTransportTimeout(() => {
1042410447
const pending = pendingSrcDocTransportProbeRef.current;
1042510448
if (
1042610449
!pending
@@ -10435,7 +10458,11 @@ function HtmlViewer({
1043510458
recoverUnacknowledgedSrcDocTransport(generation, 'probe_timeout');
1043610459
}
1043710460
}, SRC_DOC_READY_PROBE_TIMEOUT_MS);
10438-
}, [recoverUnacknowledgedSrcDocTransport]);
10461+
}, [
10462+
clearSrcDocTransportTimeouts,
10463+
recoverUnacknowledgedSrcDocTransport,
10464+
scheduleSrcDocTransportTimeout,
10465+
]);
1043910466
// Sticky once the srcDoc iframe has materialized the real artifact for the
1044010467
// first time (i.e. the first entry into Mark/Edit/Comment/Inspect). Until
1044110468
// then the srcDoc iframe stays on the lazy shell — so passive preview never
@@ -10517,6 +10544,7 @@ function HtmlViewer({
1051710544
&& pending.probeId === data.probeId
1051810545
&& data.bodyComplete === true
1051910546
) {
10547+
clearSrcDocTransportTimeouts();
1052010548
pendingSrcDocTransportProbeRef.current = null;
1052110549
srcDocParsingGraceRef.current = null;
1052210550
verifiedSrcDocTransportRef.current = { frame, generation: data.generation };
@@ -10534,6 +10562,7 @@ function HtmlViewer({
1053410562
// so keep challenging this generation without remounting and risk
1053510563
// running earlier authored side effects twice. A bounded grace
1053610564
// period still recovers Chromium's permanently-aborted half-document.
10565+
clearSrcDocTransportTimeouts();
1053710566
pendingSrcDocTransportProbeRef.current = null;
1053810567
const now = Date.now();
1053910568
const currentGrace = srcDocParsingGraceRef.current;
@@ -10556,7 +10585,7 @@ function HtmlViewer({
1055610585
});
1055710586
return;
1055810587
}
10559-
window.setTimeout(() => {
10588+
scheduleSrcDocTransportTimeout(() => {
1056010589
if (expectedSrcDocTransportGenerationRef.current !== data.generation) return;
1056110590
const verified = verifiedSrcDocTransportRef.current;
1056210591
if (verified?.frame === frame && verified.generation === data.generation) return;
@@ -10593,9 +10622,11 @@ function HtmlViewer({
1059310622
window.addEventListener('message', onMessage);
1059410623
return () => window.removeEventListener('message', onMessage);
1059510624
}, [
10625+
clearSrcDocTransportTimeouts,
1059610626
probeSrcDocTransport,
1059710627
recoverUnacknowledgedSrcDocTransport,
1059810628
replayPreviewBridgeModes,
10629+
scheduleSrcDocTransportTimeout,
1059910630
workspaceActive,
1060010631
]);
1060110632
// React can commit a fresh `srcdoc` attribute while Chromium aborts the

apps/web/tests/components/FileViewer.test.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7469,6 +7469,149 @@ describe('FileViewer tweaks toolbar', () => {
74697469
}
74707470
});
74717471

7472+
it('cancels an in-flight srcDoc recovery probe when the viewer unmounts', () => {
7473+
vi.useFakeTimers();
7474+
try {
7475+
const { unmount } = render(
7476+
<FileViewer
7477+
projectId="project-1"
7478+
projectKind="prototype"
7479+
file={htmlPreviewFile({ name: 'sandbox.html', path: 'sandbox.html' })}
7480+
liveHtml={'<!doctype html><html><body><script>location.reload()</script></body></html>'}
7481+
/>,
7482+
);
7483+
7484+
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
7485+
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage');
7486+
fireEvent.load(frame);
7487+
expect(postMessage.mock.calls.some(
7488+
([message]) => (
7489+
(message as { type?: unknown }).type === 'od:srcdoc-transport-ready-probe'
7490+
),
7491+
)).toBe(true);
7492+
7493+
safetyEventMock.mockClear();
7494+
unmount();
7495+
act(() => {
7496+
vi.runAllTimers();
7497+
});
7498+
7499+
expect(safetyEventMock).not.toHaveBeenCalled();
7500+
} finally {
7501+
vi.useRealTimers();
7502+
}
7503+
});
7504+
7505+
it('restarts srcDoc verification after a retained viewer rapidly reactivates', () => {
7506+
vi.useFakeTimers();
7507+
try {
7508+
const renderViewer = (workspaceActive: boolean) => (
7509+
<FileViewer
7510+
projectId="project-1"
7511+
projectKind="prototype"
7512+
file={htmlPreviewFile({ name: 'sandbox.html', path: 'sandbox.html' })}
7513+
liveHtml={'<!doctype html><html><body><script>location.reload()</script></body></html>'}
7514+
workspaceActive={workspaceActive}
7515+
/>
7516+
);
7517+
const { rerender } = render(renderViewer(true));
7518+
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
7519+
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage');
7520+
fireEvent.load(frame);
7521+
const probes = () => postMessage.mock.calls
7522+
.map(([message]) => message as { type?: unknown; generation?: string; probeId?: string })
7523+
.filter((message) => message.type === 'od:srcdoc-transport-ready-probe');
7524+
expect(probes()).toHaveLength(1);
7525+
7526+
safetyEventMock.mockClear();
7527+
rerender(renderViewer(false));
7528+
rerender(renderViewer(true));
7529+
act(() => {
7530+
vi.advanceTimersByTime(1_500);
7531+
});
7532+
7533+
expect(screen.getByTestId('artifact-preview-frame')).toBe(frame);
7534+
expect(probes()).toHaveLength(2);
7535+
const reactivationProbe = probes()[1]!;
7536+
act(() => {
7537+
window.dispatchEvent(new MessageEvent('message', {
7538+
source: frame.contentWindow,
7539+
data: {
7540+
type: 'od:srcdoc-transport-activated',
7541+
generation: reactivationProbe.generation,
7542+
probeId: reactivationProbe.probeId,
7543+
bodyComplete: true,
7544+
},
7545+
}));
7546+
vi.runAllTimers();
7547+
});
7548+
7549+
expect(safetyEventMock).not.toHaveBeenCalled();
7550+
expect(screen.getByTestId('artifact-preview-frame')).toBe(frame);
7551+
} finally {
7552+
vi.useRealTimers();
7553+
}
7554+
});
7555+
7556+
it('cancels srcDoc verification in Code mode and restarts it on Preview', () => {
7557+
vi.useFakeTimers();
7558+
try {
7559+
render(
7560+
<FileViewer
7561+
projectId="project-1"
7562+
projectKind="prototype"
7563+
file={htmlPreviewFile({ name: 'sandbox.html', path: 'sandbox.html' })}
7564+
liveHtml={'<!doctype html><html><body><script>location.reload()</script></body></html>'}
7565+
/>,
7566+
);
7567+
7568+
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
7569+
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage');
7570+
fireEvent.load(frame);
7571+
const probes = () => postMessage.mock.calls
7572+
.map(([message]) => message as { type?: unknown; generation?: string; probeId?: string })
7573+
.filter((message) => message.type === 'od:srcdoc-transport-ready-probe');
7574+
expect(probes()).toHaveLength(1);
7575+
7576+
safetyEventMock.mockClear();
7577+
fireEvent.click(screen.getByRole('tab', { name: 'Code' }));
7578+
act(() => {
7579+
vi.advanceTimersByTime(1_500);
7580+
});
7581+
expect(safetyEventMock).not.toHaveBeenCalled();
7582+
7583+
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }));
7584+
const previewFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
7585+
const previewPostMessage = vi.spyOn(previewFrame.contentWindow!, 'postMessage');
7586+
const previewProbes = () => previewPostMessage.mock.calls
7587+
.map(([message]) => message as { type?: unknown; generation?: string; probeId?: string })
7588+
.filter((message) => message.type === 'od:srcdoc-transport-ready-probe');
7589+
const probeCountBeforeRecoveryCheck = previewProbes().length;
7590+
act(() => {
7591+
vi.advanceTimersByTime(1_500);
7592+
});
7593+
expect(previewProbes()).toHaveLength(probeCountBeforeRecoveryCheck + 1);
7594+
const previewProbe = previewProbes().at(-1)!;
7595+
act(() => {
7596+
window.dispatchEvent(new MessageEvent('message', {
7597+
source: previewFrame.contentWindow,
7598+
data: {
7599+
type: 'od:srcdoc-transport-activated',
7600+
generation: previewProbe.generation,
7601+
probeId: previewProbe.probeId,
7602+
bodyComplete: true,
7603+
},
7604+
}));
7605+
vi.runAllTimers();
7606+
});
7607+
7608+
expect(safetyEventMock).not.toHaveBeenCalled();
7609+
expect(screen.getByTestId('artifact-preview-frame')).toBe(previewFrame);
7610+
} finally {
7611+
vi.useRealTimers();
7612+
}
7613+
});
7614+
74727615
it('preserves an authored base without minting a project-scoped preview capability', async () => {
74737616
const context = teamWorkspaceContext();
74747617
const authoredBase = '<base href="https://cdn.example/assets/">';

e2e/tests/packaged-smoke-workflow.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,11 @@ process.stdin.on("end", () => {
15491549
expect(e2eVitestGate).toContain("needs: metadata");
15501550
expect(e2eVitestGate).toContain("ref: ${{ needs.metadata.outputs.commit }}");
15511551
expect(e2eVitestGate).toContain("playwright install --with-deps chromium");
1552+
expect(e2eVitestGate).toContain("Allow Chromium user namespaces on GitHub Ubuntu");
1553+
expect(e2eVitestGate).toContain("kernel.apparmor_restrict_unprivileged_userns=0");
1554+
expect(e2eVitestGate).not.toContain("--no-sandbox");
1555+
expect(e2eVitestGate.indexOf("kernel.apparmor_restrict_unprivileged_userns=0"))
1556+
.toBeLessThan(e2eVitestGate.indexOf("pnpm --filter @open-design/e2e test"));
15521557
expect(e2eVitestGate).toContain("pnpm --filter @open-design/e2e test");
15531558

15541559
const daemonGate = sectionBetween(prerelease, " daemon_unit_tests:", " verify:");

0 commit comments

Comments
 (0)