diff --git a/.gitignore b/.gitignore index 13b8318be2..cbbc6f3b88 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ typings/ # Generated dist/ .tshy-build +.wpt # Import location artifacts packages/rxjs/ajax/ diff --git a/packages/observable/.npmignore b/packages/observable/.npmignore new file mode 100644 index 0000000000..2c440e812a --- /dev/null +++ b/packages/observable/.npmignore @@ -0,0 +1,2 @@ +# Ignore spec files +dist/**/*.spec.* \ No newline at end of file diff --git a/packages/observable/package.json b/packages/observable/package.json index e296727097..8a9e907c9c 100644 --- a/packages/observable/package.json +++ b/packages/observable/package.json @@ -17,7 +17,8 @@ "build": "tshy", "lint": "eslint ./src", "test": "vitest --run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:wpt": "node ./scripts/web-platform-tests.mjs" }, "repository": { "type": "git", diff --git a/packages/observable/scripts/web-platform-tests.mjs b/packages/observable/scripts/web-platform-tests.mjs new file mode 100644 index 0000000000..500400e6db --- /dev/null +++ b/packages/observable/scripts/web-platform-tests.mjs @@ -0,0 +1,220 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const testPath = 'dom/observable/tentative'; + +const __filename = new URL(import.meta.url).pathname; +const __dirname = path.dirname(__filename); + +const wptDir = path.join(__dirname, '.wpt'); +const polyfillScriptPath = path.resolve(__dirname, '../dist/esm/polyfill.js'); + +console.log(bold(wptDir)); + +function runCommand(command, args = [], options = {}) { + return new Promise((resolve, reject) => { + // We also want to make sure we log the output from the subprocess + const subprocess = spawn(command, args, { stdio: 'inherit', ...options }); + + subprocess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`)); + } + }); + + // Log output from the subprocess + subprocess.stdout?.on('data', (data) => { + console.log(data.toString()); + }); + + subprocess.stderr?.on('data', (data) => { + console.error(data.toString()); + }); + + // Be sure that if the main process is killed, the subprocess is also killed + process.on('SIGINT', () => { + if (subprocess.exitCode === null) subprocess.kill(); + }); + }); +} + +/** + * Uses git to clone the WPT repository into the .wpt directory + * if it doesn't already exist. + * + * If it does exist, it pulls the latest changes. + */ +async function ensureWPTRepo() { + if (!fs.existsSync(wptDir)) { + console.log('Cloning WPT repository...'); + await runCommand('git', ['clone', 'https://github.com/web-platform-tests/wpt.git', wptDir]); + } else { + console.log('WPT repository already exists. Pulling latest changes...'); + await runCommand('git', ['pull'], { cwd: wptDir }); + } +} + +let unhandledErrors = null; + +process.on('unhandledRejection', (error) => { + unhandledErrors ??= []; + unhandledErrors.push(error); +}); + +process.on('uncaughtException', (error) => { + unhandledErrors ??= []; + unhandledErrors.push(error); +}); + +const testHarnessScriptPath = path.join(wptDir, 'resources', 'testharness.js'); +const testHarnessScript = fs.readFileSync(testHarnessScriptPath, 'utf8'); + +let _self; +let _window; +let _addEventListener; +let _removeEventListener; +let _dispatchEvent; + +function beforeEach() { + unhandledErrors = null; + _self = globalThis.self; + _window = globalThis.window; + _addEventListener = globalThis.addEventListener; + _removeEventListener = globalThis.removeEventListener; + _dispatchEvent = globalThis.dispatchEvent; + + globalThis.window = globalThis.self = globalThis; + + if (typeof globalThis.addEventListener !== 'function') { + const globalEventTarget = new EventTarget(); + globalThis.addEventListener = (...args) => { + globalEventTarget.addEventListener(...args); + }; + globalThis.removeEventListener = (...args) => { + globalEventTarget.removeEventListener(...args); + }; + globalThis.dispatchEvent = (...args) => { + globalEventTarget.dispatchEvent(...args); + }; + } +} + +function getUnhandledErrors() { + return new Promise((resolve) => { + setTimeout(() => { + resolve(unhandledErrors); + }); + }); +} + +function afterEach() { + globalThis.self = _self; + globalThis.window = _window; + globalThis.addEventListener = _addEventListener; + globalThis.removeEventListener = _removeEventListener; + globalThis.dispatchEvent = _dispatchEvent; +} + +function initializeTestHarness() { + const execute = new Function(testHarnessScript); + execute(); +} + +async function executeTest(test) { + beforeEach(); + try { + initializeTestHarness(); + const testComplete = new Promise((resolve) => { + add_completion_callback((tests, status) => { + resolve({ tests, status }); + }); + }); + await import(path.join(wptDir, testPath, test)); + + const unhandledErrors = await getUnhandledErrors(); + const result = await testComplete; + + return { ...result, unhandledErrors }; + } finally { + afterEach(); + } +} + +async function* runTests() { + const tests = await fs.promises.readdir(path.join(wptDir, testPath)); + const validTests = tests.filter((test) => test.endsWith('.any.js')); + for (let test of validTests) { + yield { test, result: await executeTest(test) }; + } +} + +function prettyPrintTestResult({ test, result }) { + console.log(`\n${bold(test)}:`); + if (result.unhandledErrors) { + console.log(red('Unhandled errors:')); + for (const error of result.unhandledErrors) { + console.error(error); + } + } + + for (const test of result.tests) { + if (test.status === 0) { + console.log(green('PASS'), test.name); + } else { + console.log(red('FAIL'), test.name); + console.log(test.message); + } + } +} + +async function main() { + await import(polyfillScriptPath); + + await ensureWPTRepo(); + + let totalTests = 0; + let totalFailed = 0; + let hadUnhandledErrors = false; + + for await (const testResult of runTests()) { + totalTests += testResult.result.tests.length; + totalFailed += testResult.result.tests.filter((test) => test.status !== 0).length; + if (testResult.result.unhandledErrors) { + hadUnhandledErrors = true; + } + + prettyPrintTestResult(testResult); + } + + console.log(); + console.log(bold('Summary:')); + console.log(bold(`Total tests: ${totalTests}`)); + console.log(bold(`Failed: ${totalFailed}`)); + console.log(bold(`Unhandled errors: ${hadUnhandledErrors ? 'yes' : 'no'}`)); + console.log(); + + if (hadUnhandledErrors || totalFailed > 0) { + process.exit(1); + } +} + +main(); + +function color(text, color) { + return `\x1b[${color}m${text}\x1b[0m`; +} + +function red(text) { + return color(text, 31); +} + +function bold(text) { + return color(text, 1); +} + +function green(text) { + return color(text, 32); +} diff --git a/packages/observable/src/observable.ts b/packages/observable/src/observable.ts index 31f8403d15..926a67cdb8 100644 --- a/packages/observable/src/observable.ts +++ b/packages/observable/src/observable.ts @@ -238,7 +238,7 @@ export interface SubscriberOverrides { */ export class Subscriber extends Subscription implements Observer { /** @internal */ - protected isStopped: boolean = false; + protected isStopped = false; /** @internal */ protected destination: Observer; diff --git a/packages/observable/src/polyfill.ts b/packages/observable/src/polyfill.ts new file mode 100644 index 0000000000..969d99c2d5 --- /dev/null +++ b/packages/observable/src/polyfill.ts @@ -0,0 +1,13 @@ +import { Observable } from './observable.js'; + +// get the global variable so we can polyfill Observable on it +// the environment may be a web worker, Node.js, or a browser +// we need to use the correct global context + +const _globalThis = typeof globalThis !== 'undefined' ? globalThis : typeof self !== 'undefined' ? self : global; + +// @ts-expect-error we're adding a property to the global object here +if (typeof _globalThis.Observable !== 'function') { + // @ts-expect-error we're adding a property to the global object here + _globalThis.Observable = Observable; +}