import { registerShutdownTask, runShutdownTasks } from '../../framework/on_shutdown.js'; import { LogMessageWithStack } from '../../internal/logging/log_message.js'; import { TransferredTestCaseResult, LiveTestCaseResult } from '../../internal/logging/result.js'; import { TestCaseRecorder } from '../../internal/logging/test_case_recorder.js'; import { TestQueryWithExpectation } from '../../internal/query/query.js'; import { timeout } from '../../util/timeout.js'; import { assert } from '../../util/util.js'; import { CTSOptions, WorkerMode, kDefaultCTSOptions } from './options.js'; import { WorkerTestRunRequest } from './utils_worker.js'; /** Query all currently-registered service workers, and unregister them. */ function unregisterAllServiceWorkers() { void navigator.serviceWorker.getRegistrations().then(registrations => { for (const registration of registrations) { void registration.unregister(); } }); } // In practice all Web-based runtimes should be including this file, so take the opportunity to set // up runShutdownTasks. None of these events are guaranteed to happen, but cleaning up is very // important, so we try our best (and don't worry about shutdown performance or disabling bfcache). // (We could try 'visibilitychange', but since it can happen in the middle of the page lifetime, // it is more likely to have unintended consequences and would need to do different stuff.) // - 'unload' supposedly always disables the bfcache, but is deprecated in Chrome. // - 'beforeunload' may disable the bfcache but may be called more reliably than 'unload'. window.addEventListener('beforeunload', runShutdownTasks); // - 'pagehide' won't disable the bfcache but may be called more reliably than the others. window.addEventListener('pagehide', runShutdownTasks); abstract class TestBaseWorker { protected readonly ctsOptions: CTSOptions; protected readonly resolvers = new Map void>(); constructor(worker: WorkerMode, ctsOptions?: CTSOptions) { this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker } }; } onmessage(ev: MessageEvent) { const query: string = ev.data.query; const transferredResult: TransferredTestCaseResult = ev.data.result; const result: LiveTestCaseResult = { status: transferredResult.status, timems: transferredResult.timems, logs: transferredResult.logs?.map(l => new LogMessageWithStack(l)), }; this.resolvers.get(query)!(result); this.resolvers.delete(query); // MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and // update the entire results JSON somehow at some point). } makeRequestAndRecordResult( target: MessagePort | Worker | ServiceWorker, query: string, expectations: TestQueryWithExpectation[] ): Promise { const request: WorkerTestRunRequest = { query, expectations, ctsOptions: this.ctsOptions, }; target.postMessage(request); return new Promise(resolve => { assert(!this.resolvers.has(query), "can't request same query twice simultaneously"); this.resolvers.set(query, resolve); }); } async run( rec: TestCaseRecorder, query: string, expectations: TestQueryWithExpectation[] = [] ): Promise { try { rec.injectResult(await this.runImpl(query, expectations)); } catch (ex) { rec.start(); rec.threw(ex); rec.finish(); } } protected abstract runImpl( query: string, expectations: TestQueryWithExpectation[] ): Promise; } export class TestDedicatedWorker extends TestBaseWorker { private readonly worker: Worker | Error; constructor(ctsOptions?: CTSOptions) { super('dedicated', ctsOptions); try { if (typeof Worker === 'undefined') { throw new Error('Dedicated Workers not available'); } const selfPath = import.meta.url; const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); const workerPath = selfPathDir + '/test_worker-worker.js'; const worker = new Worker(workerPath, { type: 'module' }); this.worker = worker; this.worker.onmessage = ev => this.onmessage(ev); // Try to send a shutdown signal to the worker on shutdown. registerShutdownTask(() => worker.postMessage('shutdown')); } catch (ex) { assert(ex instanceof Error); // Save the exception to re-throw in runImpl(). this.worker = ex; } } override runImpl(query: string, expectations: TestQueryWithExpectation[] = []) { if (this.worker instanceof Worker) { return this.makeRequestAndRecordResult(this.worker, query, expectations); } else { throw this.worker; } } } /** @deprecated Use TestDedicatedWorker instead. */ export class TestWorker extends TestDedicatedWorker {} export class TestSharedWorker extends TestBaseWorker { /** MessagePort to the SharedWorker, or an Error if it couldn't be initialized. */ private readonly port: MessagePort | Error; constructor(ctsOptions?: CTSOptions) { super('shared', ctsOptions); try { if (typeof SharedWorker === 'undefined') { throw new Error('Shared Workers not available'); } const selfPath = import.meta.url; const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); const workerPath = selfPathDir + '/test_worker-worker.js'; const worker = new SharedWorker(workerPath, { type: 'module' }); this.port = worker.port; this.port.start(); this.port.onmessage = ev => this.onmessage(ev); // Try to send a shutdown signal to the worker on shutdown. registerShutdownTask(() => worker.port.postMessage('shutdown')); } catch (ex) { assert(ex instanceof Error); // Save the exception to re-throw in runImpl(). this.port = ex; } } override runImpl(query: string, expectations: TestQueryWithExpectation[] = []) { if (this.port instanceof MessagePort) { return this.makeRequestAndRecordResult(this.port, query, expectations); } else { throw this.port; } } } export class TestServiceWorker extends TestBaseWorker { private lastServiceWorker: ServiceWorker | null = null; constructor(ctsOptions?: CTSOptions) { super('service', ctsOptions); // If the runtime is trying to use service workers, first clean up any service workers that may // have leaked from previous incarnations of the page. Service workers *shouldn't* affect // worker=none/dedicated/shared, so it should be OK if this constructor isn't called. unregisterAllServiceWorkers(); // Try to send a shutdown signal to the worker on shutdown. registerShutdownTask(() => { this.runShutdownTasksOnLastServiceWorker(); unregisterAllServiceWorkers(); }); } private runShutdownTasksOnLastServiceWorker() { this.lastServiceWorker?.postMessage('shutdown'); } override async runImpl(query: string, expectations: TestQueryWithExpectation[] = []) { if (!('serviceWorker' in navigator)) { throw new Error('Service Workers not available'); } const [suite, name] = query.split(':', 2); const fileName = name.split(',').join('/'); const selfPath = import.meta.url; const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); // Construct the path to the worker file, then use URL to resolve the `../` components. const serviceWorkerURL = new URL( `${selfPathDir}/../../../${suite}/webworker/${fileName}.as_worker.js` ).toString(); // Ensure the correct service worker is registered. const registration = await navigator.serviceWorker.register(serviceWorkerURL, { type: 'module', }); const registrationPending = () => !registration.active || registration.active.scriptURL !== serviceWorkerURL; // Ensure the registration we just requested is active. (We don't worry about it being // outdated from a previous page load, because we wipe all service workers on shutdown/startup.) const isNewInstance = registrationPending(); if (isNewInstance) { this.runShutdownTasksOnLastServiceWorker(); do { await new Promise(resolve => timeout(resolve, 0)); } while (registrationPending()); } const serviceWorker = registration.active!; this.lastServiceWorker = serviceWorker; navigator.serviceWorker.onmessage = ev => this.onmessage(ev); return this.makeRequestAndRecordResult(serviceWorker, query, expectations); } }