Skip to content

Commit 76b7b91

Browse files
committed
chore(trace): survive the sw restart in most cases
1 parent ea3fab6 commit 76b7b91

File tree

5 files changed

+159
-99
lines changed

5 files changed

+159
-99
lines changed

packages/playwright-core/src/utils/isomorphic/mimeType.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ const types: Map<string, string> = new Map([
430430
['jpgm', 'video/jpm'],
431431
['mj2', 'video/mj2'],
432432
['mjp2', 'video/mj2'],
433-
['ts', 'video/mp2t'],
433+
['ts', 'application/typescript'],
434434
['mp4', 'video/mp4'],
435435
['mp4v', 'video/mp4'],
436436
['mpg4', 'video/mp4'],

packages/trace-viewer/src/sw/main.ts

Lines changed: 138 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { splitProgress } from './progress';
17+
import { Progress, splitProgress } from './progress';
1818
import { SnapshotServer } from './snapshotServer';
1919
import { TraceModel } from './traceModel';
2020
import { FetchTraceModelBackend, traceFileURL, ZipTraceModelBackend } from './traceModelBackends';
@@ -41,6 +41,13 @@ type ServiceWorkerGlobalScope = {
4141
skipWaiting(): Promise<void>;
4242
};
4343

44+
type FetchEvent = {
45+
request: Request;
46+
clientId: string | null;
47+
resultingClientId: string;
48+
respondWith(response: Promise<Response>): void;
49+
};
50+
4451
declare const self: ServiceWorkerGlobalScope;
4552

4653
self.addEventListener('install', function(event: any) {
@@ -57,145 +64,178 @@ type LoadedTrace = {
5764
};
5865

5966
const scopePath = new URL(self.registration.scope).pathname;
60-
const loadedTraces = new Map<string, LoadedTrace>();
67+
const loadedTraces = new Map<string, Promise<LoadedTrace>>();
6168
const clientIdToTraceUrls = new Map<string, string>();
6269
const isDeployedAsHttps = self.registration.scope.startsWith('https://');
6370

64-
async function loadTrace(traceUrl: string, traceFileName: string | null, client: Client): Promise<TraceModel> {
65-
const clientId = client.id;
71+
function simulateRestart() {
72+
loadedTraces.clear();
73+
clientIdToTraceUrls.clear();
74+
}
75+
76+
async function loadTraceOrError(clientId: string, url: URL, isContextRequest: boolean, progress: Progress): Promise<{ loadedTrace?: LoadedTrace, errorResponse?: Response }> {
77+
try {
78+
const loadedTrace = await loadTrace(clientId, url, isContextRequest, progress);
79+
return { loadedTrace };
80+
} catch (error) {
81+
return {
82+
errorResponse: new Response(JSON.stringify({ error: error?.message }), {
83+
status: 500,
84+
headers: { 'Content-Type': 'application/json' }
85+
})
86+
};
87+
}
88+
}
89+
90+
function loadTrace(clientId: string, url: URL, isContextRequest: boolean, progress: Progress): Promise<LoadedTrace> {
91+
const traceUrl = url.searchParams.get('trace')!;
92+
if (!traceUrl)
93+
throw new Error('trace parameter is missing');
94+
6695
clientIdToTraceUrls.set(clientId, traceUrl);
96+
const omitCache = isContextRequest && isLiveTrace(traceUrl);
97+
const loadedTrace = omitCache ? undefined : loadedTraces.get(traceUrl);
98+
if (loadedTrace)
99+
return loadedTrace;
100+
const promise = innerLoadTrace(traceUrl, progress);
101+
loadedTraces.set(traceUrl, promise);
102+
return promise;
103+
}
104+
105+
async function innerLoadTrace(traceUrl: string, progress: Progress): Promise<LoadedTrace> {
67106
await gc();
68107

69108
const traceModel = new TraceModel();
70109
try {
71110
// Allow 10% to hop from sw to page.
72-
const [fetchProgress, unzipProgress] = splitProgress((done: number, total: number) => {
73-
client.postMessage({ method: 'progress', params: { done, total } });
74-
}, [0.5, 0.4, 0.1]);
75-
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
111+
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
112+
const backend = isLiveTrace(traceUrl) ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
76113
await traceModel.load(backend, unzipProgress);
77114
} catch (error: any) {
78115
// eslint-disable-next-line no-console
79116
console.error(error);
80117
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
81118
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');
82119
if (error instanceof TraceVersionError)
83-
throw new Error(`Could not load trace from ${traceFileName || traceUrl}. ${error.message}`);
84-
if (traceFileName)
85-
throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`);
120+
throw new Error(`Could not load trace from ${traceUrl}. ${error.message}`);
86121
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
87122
}
88123
const snapshotServer = new SnapshotServer(traceModel.storage(), sha1 => traceModel.resourceForSha1(sha1));
89-
loadedTraces.set(traceUrl, { traceModel, snapshotServer });
90-
return traceModel;
124+
return { traceModel, snapshotServer };
91125
}
92126

93-
// @ts-ignore
94127
async function doFetch(event: FetchEvent): Promise<Response> {
128+
const request = event.request;
129+
95130
// In order to make Accessibility Insights for Web work.
96-
if (event.request.url.startsWith('chrome-extension://'))
97-
return fetch(event.request);
131+
if (request.url.startsWith('chrome-extension://'))
132+
return fetch(request);
98133

99-
if (event.request.headers.get('x-pw-serviceworker') === 'forward') {
134+
if (request.headers.get('x-pw-serviceworker') === 'forward') {
100135
const request = new Request(event.request);
101136
request.headers.delete('x-pw-serviceworker');
102137
return fetch(request);
103138
}
104139

105-
const request = event.request;
106-
const client = await self.clients.get(event.clientId) as Client | undefined;
107-
108-
// When trace viewer is deployed over https, we will force upgrade
109-
// insecure http subresources to https. Otherwise, these will fail
110-
// to load inside our https snapshots.
111-
// In this case, we also match http resources from the archive by
112-
// the https urls.
113140
const url = new URL(request.url);
114-
115141
let relativePath: string | undefined;
116142
if (request.url.startsWith(self.registration.scope))
117143
relativePath = url.pathname.substring(scopePath.length - 1);
118144

145+
if (relativePath === '/restartServiceWorker') {
146+
simulateRestart();
147+
return new Response(null, { status: 200 });
148+
}
149+
119150
if (relativePath === '/ping')
120151
return new Response(null, { status: 200 });
121152

122-
if (relativePath === '/contexts') {
123-
const traceUrl = url.searchParams.get('trace');
124-
if (!client || !traceUrl) {
125-
return new Response('Something went wrong, trace is requested as a part of the navigation', {
126-
status: 500,
127-
headers: { 'Content-Type': 'application/json' }
128-
});
153+
const isNavigation = !!event.resultingClientId;
154+
const client = event.clientId ? await self.clients.get(event.clientId) : undefined;
155+
156+
if (isNavigation && !relativePath?.startsWith('/sha1/')) {
157+
// Navigation request. Download is a /sha1/ navigation, ignore them here.
158+
159+
// Snapshot iframe navigation request.
160+
if (relativePath?.startsWith('/snapshot/')) {
161+
// It is Ok to pass noop progress as the trace is likely already loaded.
162+
const { errorResponse, loadedTrace } = await loadTraceOrError(event.resultingClientId!, url, false, noopProgress);
163+
if (errorResponse)
164+
return errorResponse;
165+
const pageOrFrameId = relativePath.substring('/snapshot/'.length);
166+
const response = loadedTrace!.snapshotServer.serveSnapshot(pageOrFrameId, url.searchParams, url.href);
167+
if (isDeployedAsHttps)
168+
response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests');
169+
return response;
129170
}
130171

131-
try {
132-
const traceModel = await loadTrace(traceUrl, url.searchParams.get('traceFileName'), client);
133-
return new Response(JSON.stringify(traceModel.contextEntries), {
134-
status: 200,
135-
headers: { 'Content-Type': 'application/json' }
136-
});
137-
} catch (error: any) {
138-
return new Response(JSON.stringify({ error: error?.message }), {
139-
status: 500,
140-
headers: { 'Content-Type': 'application/json' }
141-
});
142-
}
172+
// Static content navigation request for trace viewer or popout.
173+
return fetch(event.request);
143174
}
144175

145-
if (relativePath?.startsWith('/snapshotInfo/')) {
146-
const { snapshotServer } = loadedTrace(url);
147-
if (!snapshotServer)
148-
return new Response(null, { status: 404 });
149-
const pageOrFrameId = relativePath.substring('/snapshotInfo/'.length);
150-
return snapshotServer.serveSnapshotInfo(pageOrFrameId, url.searchParams);
151-
}
176+
if (!relativePath) {
177+
// Out-of-scope sub-resource request => iframe snapshot sub-resources.
178+
if (!client)
179+
return new Response('Sub-resource without a client', { status: 500 });
152180

153-
if (relativePath?.startsWith('/snapshot/')) {
154-
const { snapshotServer } = loadedTrace(url);
181+
const { snapshotServer } = await loadTrace(client.id, new URL(client.url), false, clientProgress(client));
155182
if (!snapshotServer)
156183
return new Response(null, { status: 404 });
157-
const pageOrFrameId = relativePath.substring('/snapshot/'.length);
158-
const response = snapshotServer.serveSnapshot(pageOrFrameId, url.searchParams, url.href);
159-
if (isDeployedAsHttps)
160-
response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests');
161-
return response;
162-
}
163184

164-
if (relativePath?.startsWith('/closest-screenshot/')) {
165-
const { snapshotServer } = loadedTrace(url);
166-
if (!snapshotServer)
167-
return new Response(null, { status: 404 });
168-
const pageOrFrameId = relativePath.substring('/closest-screenshot/'.length);
169-
return snapshotServer.serveClosestScreenshot(pageOrFrameId, url.searchParams);
185+
// When trace viewer is deployed over https, we will force upgrade
186+
// insecure http sub-resources to https. Otherwise, these will fail
187+
// to load inside our https snapshots.
188+
// In this case, we also match http resources from the archive by
189+
// the https urls.
190+
const lookupUrls = [request.url];
191+
if (isDeployedAsHttps && request.url.startsWith('https://'))
192+
lookupUrls.push(request.url.replace(/^https/, 'http'));
193+
return snapshotServer.serveResource(lookupUrls, request.method, client!.url);
170194
}
171195

172-
if (relativePath?.startsWith('/sha1/')) {
173-
const { traceModel } = loadedTrace(url);
174-
const blob = await traceModel?.resourceForSha1(relativePath.slice('/sha1/'.length));
175-
if (blob)
176-
return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) });
177-
return new Response(null, { status: 404 });
196+
// These commands all require a loaded trace.
197+
if (relativePath === '/contexts' || relativePath?.startsWith('/snapshotInfo/') || relativePath?.startsWith('/closest-screenshot/') || relativePath?.startsWith('/sha1/')) {
198+
if (!client)
199+
return new Response('Sub-resource without a client', { status: 500 });
200+
201+
const isContextRequest = relativePath === '/contexts';
202+
const { errorResponse, loadedTrace } = await loadTraceOrError(client.id, url, isContextRequest, clientProgress(client));
203+
if (errorResponse)
204+
return errorResponse;
205+
206+
if (relativePath === '/contexts') {
207+
return new Response(JSON.stringify(loadedTrace!.traceModel.contextEntries), {
208+
status: 200,
209+
headers: { 'Content-Type': 'application/json' }
210+
});
211+
}
212+
213+
if (relativePath?.startsWith('/snapshotInfo/')) {
214+
const pageOrFrameId = relativePath.substring('/snapshotInfo/'.length);
215+
return loadedTrace!.snapshotServer.serveSnapshotInfo(pageOrFrameId, url.searchParams);
216+
}
217+
218+
if (relativePath?.startsWith('/closest-screenshot/')) {
219+
const pageOrFrameId = relativePath.substring('/closest-screenshot/'.length);
220+
return loadedTrace!.snapshotServer.serveClosestScreenshot(pageOrFrameId, url.searchParams);
221+
}
222+
223+
if (relativePath?.startsWith('/sha1/')) {
224+
const blob = await loadedTrace!.traceModel.resourceForSha1(relativePath.slice('/sha1/'.length));
225+
if (blob)
226+
return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) });
227+
return new Response(null, { status: 404 });
228+
}
178229
}
179230

231+
// Pass through to the server for file requests.
180232
if (relativePath?.startsWith('/file/')) {
181233
const path = url.searchParams.get('path')!;
182234
return await fetch(traceFileURL(path));
183235
}
184236

185-
// Fallback for static assets.
186-
if (relativePath)
187-
return fetch(event.request);
188-
189-
const snapshotUrl = client!.url;
190-
const traceUrl = new URL(snapshotUrl).searchParams.get('trace')!;
191-
const { snapshotServer } = loadedTraces.get(traceUrl) || {};
192-
if (!snapshotServer)
193-
return new Response(null, { status: 404 });
194-
195-
const lookupUrls = [request.url];
196-
if (isDeployedAsHttps && request.url.startsWith('https://'))
197-
lookupUrls.push(request.url.replace(/^https/, 'http'));
198-
return snapshotServer.serveResource(lookupUrls, request.method, snapshotUrl);
237+
// Static content for sub-resource.
238+
return fetch(event.request);
199239
}
200240

201241
function downloadHeaders(searchParams: URLSearchParams): Headers | undefined {
@@ -210,13 +250,6 @@ function downloadHeaders(searchParams: URLSearchParams): Headers | undefined {
210250
return headers;
211251
}
212252

213-
const emptyLoadedTrace = { traceModel: undefined, snapshotServer: undefined };
214-
215-
function loadedTrace(url: URL): LoadedTrace | { traceModel: undefined, snapshotServer: undefined } {
216-
const traceUrl = url.searchParams.get('trace');
217-
return traceUrl ? loadedTraces.get(traceUrl) ?? emptyLoadedTrace : emptyLoadedTrace;
218-
}
219-
220253
async function gc() {
221254
const clients = await self.clients.matchAll();
222255
const usedTraces = new Set<string>();
@@ -236,7 +269,18 @@ async function gc() {
236269
}
237270
}
238271

239-
// @ts-ignore
272+
function clientProgress(client: Client): Progress {
273+
return (done: number, total: number) => {
274+
client.postMessage({ method: 'progress', params: { done, total } });
275+
};
276+
}
277+
278+
function noopProgress(done: number, total: number): undefined { }
279+
280+
function isLiveTrace(traceUrl: string): boolean {
281+
return traceUrl.endsWith('.json');
282+
}
283+
240284
self.addEventListener('fetch', function(event: FetchEvent) {
241285
event.respondWith(doFetch(event));
242286
});

packages/trace-viewer/src/sw/progress.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
type Progress = (done: number, total: number) => undefined;
17+
export type Progress = (done: number, total: number) => undefined;
1818

1919
export function splitProgress(progress: Progress, weights: number[]): Progress[] {
2020
const doneList = new Array(weights.length).fill(0);

packages/trace-viewer/src/ui/workbenchLoader.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export const WorkbenchLoader: React.FunctionComponent<{
4343
const file = files.item(0)!;
4444
const blobTraceURL = URL.createObjectURL(file);
4545
url.searchParams.append('trace', blobTraceURL);
46-
url.searchParams.append('traceFileName', file.name);
4746
const href = url.toString();
4847
// Snapshot loaders will inherit the trace url from the query parameters,
4948
// so set it here.
@@ -143,8 +142,6 @@ export const WorkbenchLoader: React.FunctionComponent<{
143142

144143
const params = new URLSearchParams();
145144
params.set('trace', traceURL);
146-
if (uploadedTraceName)
147-
params.set('traceFileName', uploadedTraceName);
148145
const response = await fetch(`contexts?${params.toString()}`);
149146
if (!response.ok) {
150147
if (!isServer)

tests/library/trace-viewer.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,3 +1981,22 @@ test.describe(() => {
19811981
await expect(frame.getByRole('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
19821982
});
19831983
});
1984+
1985+
test('should survive service worker restart', async ({ page, runAndTrace, server }) => {
1986+
const traceViewer = await runAndTrace(async () => {
1987+
await page.goto(server.EMPTY_PAGE);
1988+
await page.setContent('Old world');
1989+
await page.evaluate(() => document.body.textContent = 'New world');
1990+
});
1991+
const snapshot1 = await traceViewer.snapshotFrame('Evaluate');
1992+
await expect(snapshot1.locator('body')).toHaveText('New world');
1993+
1994+
const status = await traceViewer.page.evaluate(async () => {
1995+
const response = await fetch('restartServiceWorker');
1996+
return response.status;
1997+
});
1998+
expect(status).toBe(200);
1999+
2000+
const snapshot2 = await traceViewer.snapshotFrame('Set content');
2001+
await expect(snapshot2.locator('body')).toHaveText('Old world');
2002+
});

0 commit comments

Comments
 (0)