diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /dom/webgpu/tests/cts/checkout/src/common | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/webgpu/tests/cts/checkout/src/common')
56 files changed, 6969 insertions, 0 deletions
diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/data_cache.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/data_cache.ts new file mode 100644 index 0000000000..6f6e80288a --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/data_cache.ts @@ -0,0 +1,120 @@ +/** + * Utilities to improve the performance of the CTS, by caching data that is + * expensive to build using a two-level cache (in-memory, pre-computed file). + */ + +interface DataStore { + load(path: string): Promise<string>; +} + +/** Logger is a basic debug logger function */ +export type Logger = (s: string) => void; + +/** DataCache is an interface to a data store used to hold cached data */ +export class DataCache { + /** setDataStore() sets the backing data store used by the data cache */ + public setStore(dataStore: DataStore) { + this.dataStore = dataStore; + } + + /** setDebugLogger() sets the verbose logger */ + public setDebugLogger(logger: Logger) { + this.debugLogger = logger; + } + + /** + * fetch() retrieves cacheable data from the data cache, first checking the + * in-memory cache, then the data store (if specified), then resorting to + * building the data and storing it in the cache. + */ + public async fetch<Data>(cacheable: Cacheable<Data>): Promise<Data> { + // First check the in-memory cache + let data = this.cache.get(cacheable.path); + if (data !== undefined) { + this.log('in-memory cache hit'); + return Promise.resolve(data as Data); + } + this.log('in-memory cache miss'); + // In in-memory cache miss. + // Next, try the data store. + if (this.dataStore !== null && !this.unavailableFiles.has(cacheable.path)) { + let serialized: string | undefined; + try { + serialized = await this.dataStore.load(cacheable.path); + this.log('loaded serialized'); + } catch (err) { + // not found in data store + this.log(`failed to load (${cacheable.path}): ${err}`); + this.unavailableFiles.add(cacheable.path); + } + if (serialized !== undefined) { + this.log(`deserializing`); + data = cacheable.deserialize(serialized); + this.cache.set(cacheable.path, data); + return data as Data; + } + } + // Not found anywhere. Build the data, and cache for future lookup. + this.log(`cache: building (${cacheable.path})`); + data = await cacheable.build(); + this.cache.set(cacheable.path, data); + return data as Data; + } + + private log(msg: string) { + if (this.debugLogger !== null) { + this.debugLogger(`DataCache: ${msg}`); + } + } + + private cache = new Map<string, unknown>(); + private unavailableFiles = new Set<string>(); + private dataStore: DataStore | null = null; + private debugLogger: Logger | null = null; +} + +/** The data cache */ +export const dataCache = new DataCache(); + +/** true if the current process is building the cache */ +let isBuildingDataCache = false; + +/** @returns true if the data cache is currently being built */ +export function getIsBuildingDataCache() { + return isBuildingDataCache; +} + +/** Sets whether the data cache is currently being built */ +export function setIsBuildingDataCache(value = true) { + isBuildingDataCache = value; +} + +/** + * Cacheable is the interface to something that can be stored into the + * DataCache. + * The 'npm run gen_cache' tool will look for module-scope variables of this + * interface, with the name `d`. + */ +export interface Cacheable<Data> { + /** the globally unique path for the cacheable data */ + readonly path: string; + + /** + * build() builds the cacheable data. + * This is assumed to be an expensive operation and will only happen if the + * cache does not already contain the built data. + */ + build(): Promise<Data>; + + /** + * serialize() transforms `data` to a string (usually JSON encoded) so that it + * can be stored in a text cache file. + */ + serialize(data: Data): string; + + /** + * deserialize() is the inverse of serialize(), transforming the string back + * to the Data object. + */ + deserialize(serialized: string): Data; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/fixture.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/fixture.ts new file mode 100644 index 0000000000..1368a3f96e --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/fixture.ts @@ -0,0 +1,328 @@ +import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; +import { JSONWithUndefined } from '../internal/params_utils.js'; +import { assert, unreachable } from '../util/util.js'; + +export class SkipTestCase extends Error {} +export class UnexpectedPassError extends Error {} + +export { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; + +/** The fully-general type for params passed to a test function invocation. */ +export type TestParams = { + readonly [k: string]: JSONWithUndefined; +}; + +type DestroyableObject = + | { destroy(): void } + | { close(): void } + | { getExtension(extensionName: 'WEBGL_lose_context'): WEBGL_lose_context }; + +export class SubcaseBatchState { + private _params: TestParams; + + constructor(params: TestParams) { + this._params = params; + } + + /** + * Returns the case parameters for this test fixture shared state. Subcase params + * are not included. + */ + get params(): TestParams { + return this._params; + } + + /** + * Runs before the `.before()` function. + * @internal MAINTENANCE_TODO: Make this not visible to test code? + */ + async init() {} + /** + * Runs between the `.before()` function and the subcases. + * @internal MAINTENANCE_TODO: Make this not visible to test code? + */ + async postInit() {} + /** + * Runs after all subcases finish. + * @internal MAINTENANCE_TODO: Make this not visible to test code? + */ + async finalize() {} +} + +/** + * A Fixture is a class used to instantiate each test sub/case at run time. + * A new instance of the Fixture is created for every single test subcase + * (i.e. every time the test function is run). + */ +export class Fixture<S extends SubcaseBatchState = SubcaseBatchState> { + private _params: unknown; + private _sharedState: S; + /** + * Interface for recording logs and test status. + * + * @internal + */ + protected rec: TestCaseRecorder; + private eventualExpectations: Array<Promise<unknown>> = []; + private numOutstandingAsyncExpectations = 0; + private objectsToCleanUp: DestroyableObject[] = []; + + public static MakeSharedState(params: TestParams): SubcaseBatchState { + return new SubcaseBatchState(params); + } + + /** @internal */ + constructor(sharedState: S, rec: TestCaseRecorder, params: TestParams) { + this._sharedState = sharedState; + this.rec = rec; + this._params = params; + } + + /** + * Returns the (case+subcase) parameters for this test function invocation. + */ + get params(): unknown { + return this._params; + } + + /** + * Gets the test fixture's shared state. This object is shared between subcases + * within the same testcase. + */ + get sharedState(): S { + return this._sharedState; + } + + /** + * Override this to do additional pre-test-function work in a derived fixture. + * This has to be a member function instead of an async `createFixture` function, because + * we need to be able to ergonomically override it in subclasses. + * + * @internal MAINTENANCE_TODO: Make this not visible to test code? + */ + async init(): Promise<void> {} + + /** + * Override this to do additional post-test-function work in a derived fixture. + * + * Called even if init was unsuccessful. + * + * @internal MAINTENANCE_TODO: Make this not visible to test code? + */ + async finalize(): Promise<void> { + assert( + this.numOutstandingAsyncExpectations === 0, + 'there were outstanding immediateAsyncExpectations (e.g. expectUncapturedError) at the end of the test' + ); + + // Loop to exhaust the eventualExpectations in case they chain off each other. + while (this.eventualExpectations.length) { + const p = this.eventualExpectations.shift()!; + try { + await p; + } catch (ex) { + this.rec.threw(ex); + } + } + + // And clean up any objects now that they're done being used. + for (const o of this.objectsToCleanUp) { + if ('getExtension' in o) { + const WEBGL_lose_context = o.getExtension('WEBGL_lose_context'); + if (WEBGL_lose_context) WEBGL_lose_context.loseContext(); + } else if ('destroy' in o) { + o.destroy(); + } else { + o.close(); + } + } + } + + /** + * Tracks an object to be cleaned up after the test finishes. + * + * MAINTENANCE_TODO: Use this in more places. (Will be easier once .destroy() is allowed on + * invalid objects.) + */ + trackForCleanup<T extends DestroyableObject>(o: T): T { + this.objectsToCleanUp.push(o); + return o; + } + + /** Tracks an object, if it's destroyable, to be cleaned up after the test finishes. */ + tryTrackForCleanup<T>(o: T): T { + if (typeof o === 'object' && o !== null) { + if ( + 'destroy' in o || + 'close' in o || + o instanceof WebGLRenderingContext || + o instanceof WebGL2RenderingContext + ) { + this.objectsToCleanUp.push((o as unknown) as DestroyableObject); + } + } + return o; + } + + /** Log a debug message. */ + debug(msg: string): void { + this.rec.debug(new Error(msg)); + } + + /** Throws an exception marking the subcase as skipped. */ + skip(msg: string): never { + throw new SkipTestCase(msg); + } + + /** Log a warning and increase the result status to "Warn". */ + warn(msg?: string): void { + this.rec.warn(new Error(msg)); + } + + /** Log an error and increase the result status to "ExpectFailed". */ + fail(msg?: string): void { + this.rec.expectationFailed(new Error(msg)); + } + + /** + * Wraps an async function. Tracks its status to fail if the test tries to report a test status + * before the async work has finished. + */ + protected async immediateAsyncExpectation<T>(fn: () => Promise<T>): Promise<T> { + this.numOutstandingAsyncExpectations++; + const ret = await fn(); + this.numOutstandingAsyncExpectations--; + return ret; + } + + /** + * Wraps an async function, passing it an `Error` object recording the original stack trace. + * The async work will be implicitly waited upon before reporting a test status. + */ + protected eventualAsyncExpectation<T>(fn: (niceStack: Error) => Promise<T>): void { + const promise = fn(new Error()); + this.eventualExpectations.push(promise); + } + + private expectErrorValue(expectedError: string | true, ex: unknown, niceStack: Error): void { + if (!(ex instanceof Error)) { + niceStack.message = `THREW non-error value, of type ${typeof ex}: ${ex}`; + this.rec.expectationFailed(niceStack); + return; + } + const actualName = ex.name; + if (expectedError !== true && actualName !== expectedError) { + niceStack.message = `THREW ${actualName}, instead of ${expectedError}: ${ex}`; + this.rec.expectationFailed(niceStack); + } else { + niceStack.message = `OK: threw ${actualName}: ${ex.message}`; + this.rec.debug(niceStack); + } + } + + /** Expect that the provided promise resolves (fulfills). */ + shouldResolve(p: Promise<unknown>, msg?: string): void { + this.eventualAsyncExpectation(async niceStack => { + const m = msg ? ': ' + msg : ''; + try { + await p; + niceStack.message = 'resolved as expected' + m; + } catch (ex) { + niceStack.message = `REJECTED${m}`; + if (ex instanceof Error) { + niceStack.message += '\n' + ex.message; + } + this.rec.expectationFailed(niceStack); + } + }); + } + + /** Expect that the provided promise rejects, with the provided exception name. */ + shouldReject(expectedName: string, p: Promise<unknown>, msg?: string): void { + this.eventualAsyncExpectation(async niceStack => { + const m = msg ? ': ' + msg : ''; + try { + await p; + niceStack.message = 'DID NOT REJECT' + m; + this.rec.expectationFailed(niceStack); + } catch (ex) { + niceStack.message = 'rejected as expected' + m; + this.expectErrorValue(expectedName, ex, niceStack); + } + }); + } + + /** + * Expect that the provided function throws. + * If an `expectedName` is provided, expect that the throw exception has that name. + */ + shouldThrow(expectedError: string | boolean, fn: () => void, msg?: string): void { + const m = msg ? ': ' + msg : ''; + try { + fn(); + if (expectedError === false) { + this.rec.debug(new Error('did not throw, as expected' + m)); + } else { + this.rec.expectationFailed(new Error('unexpectedly did not throw' + m)); + } + } catch (ex) { + if (expectedError === false) { + this.rec.expectationFailed(new Error('threw unexpectedly' + m)); + } else { + this.expectErrorValue(expectedError, ex, new Error(m)); + } + } + } + + /** Expect that a condition is true. */ + expect(cond: boolean, msg?: string): boolean { + if (cond) { + const m = msg ? ': ' + msg : ''; + this.rec.debug(new Error('expect OK' + m)); + } else { + this.rec.expectationFailed(new Error(msg)); + } + return cond; + } + + /** + * If the argument is an `Error`, fail (or warn). If it's `undefined`, no-op. + * If the argument is an array, apply the above behavior on each of elements. + */ + expectOK( + error: Error | undefined | (Error | undefined)[], + { mode = 'fail', niceStack }: { mode?: 'fail' | 'warn'; niceStack?: Error } = {} + ): void { + const handleError = (error: Error | undefined) => { + if (error instanceof Error) { + if (niceStack) { + error.stack = niceStack.stack; + } + if (mode === 'fail') { + this.rec.expectationFailed(error); + } else if (mode === 'warn') { + this.rec.warn(error); + } else { + unreachable(); + } + } + }; + + if (Array.isArray(error)) { + for (const e of error) { + handleError(e); + } + } else { + handleError(error); + } + } + + eventualExpectOK( + error: Promise<Error | undefined | (Error | undefined)[]>, + { mode = 'fail' }: { mode?: 'fail' | 'warn' } = {} + ) { + this.eventualAsyncExpectation(async niceStack => { + this.expectOK(await error, { mode, niceStack }); + }); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/params_builder.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/params_builder.ts new file mode 100644 index 0000000000..d22444a9b6 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/params_builder.ts @@ -0,0 +1,337 @@ +import { Merged, mergeParams } from '../internal/params_utils.js'; +import { stringifyPublicParams } from '../internal/query/stringify_params.js'; +import { assert, mapLazy } from '../util/util.js'; + +// ================================================================ +// "Public" ParamsBuilder API / Documentation +// ================================================================ + +/** + * Provides doc comments for the methods of CaseParamsBuilder and SubcaseParamsBuilder. + * (Also enforces rough interface match between them.) + */ +export interface ParamsBuilder { + /** + * Expands each item in `this` into zero or more items. + * Each item has its parameters expanded with those returned by the `expander`. + * + * **Note:** When only a single key is being added, use the simpler `expand` for readability. + * + * ```text + * this = [ a , b , c ] + * this.map(expander) = [ f(a) f(b) f(c) ] + * = [[a1, a2, a3] , [ b1 ] , [] ] + * merge and flatten = [ merge(a, a1), merge(a, a2), merge(a, a3), merge(b, b1) ] + * ``` + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + expandWithParams(expander: (_: any) => any): any; + + /** + * Expands each item in `this` into zero or more items. Each item has its parameters expanded + * with one new key, `key`, and the values returned by `expander`. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + expand(key: string, expander: (_: any) => any): any; + + /** + * Expands each item in `this` to multiple items, one for each item in `newParams`. + * + * In other words, takes the cartesian product of [ the items in `this` ] and `newParams`. + * + * **Note:** When only a single key is being added, use the simpler `combine` for readability. + * + * ```text + * this = [ {a:1}, {b:2} ] + * newParams = [ {x:1}, {y:2} ] + * this.combineP(newParams) = [ {a:1,x:1}, {a:1,y:2}, {b:2,x:1}, {b:2,y:2} ] + * ``` + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + combineWithParams(newParams: Iterable<any>): any; + + /** + * Expands each item in `this` to multiple items with `{ [name]: value }` for each value. + * + * In other words, takes the cartesian product of [ the items in `this` ] + * and `[ {[name]: value} for each value in values ]` + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + combine(key: string, newParams: Iterable<any>): any; + + /** + * Filters `this` to only items for which `pred` returns true. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + filter(pred: (_: any) => boolean): any; + + /** + * Filters `this` to only items for which `pred` returns false. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + unless(pred: (_: any) => boolean): any; +} + +/** + * Determines the resulting parameter object type which would be generated by an object of + * the given ParamsBuilder type. + */ +export type ParamTypeOf< + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + T extends ParamsBuilder +> = T extends SubcaseParamsBuilder<infer CaseP, infer SubcaseP> + ? Merged<CaseP, SubcaseP> + : T extends CaseParamsBuilder<infer CaseP> + ? CaseP + : never; + +// ================================================================ +// Implementation +// ================================================================ + +/** + * Iterable over pairs of either: + * - `[case params, Iterable<subcase params>]` if there are subcases. + * - `[case params, undefined]` if not. + */ +export type CaseSubcaseIterable<CaseP, SubcaseP> = Iterable< + readonly [CaseP, Iterable<SubcaseP> | undefined] +>; + +/** + * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`. + */ +export abstract class ParamsBuilderBase<CaseP extends {}, SubcaseP extends {}> { + protected readonly cases: () => Generator<CaseP>; + + constructor(cases: () => Generator<CaseP>) { + this.cases = cases; + } + + /** + * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this. + */ + protected abstract iterateCasesWithSubcases(): CaseSubcaseIterable<CaseP, SubcaseP>; +} + +/** + * Calls the (normally hidden) `iterateCasesWithSubcases()` method. + */ +export function builderIterateCasesWithSubcases(builder: ParamsBuilderBase<{}, {}>) { + interface IterableParamsBuilder { + iterateCasesWithSubcases(): CaseSubcaseIterable<{}, {}>; + } + + return ((builder as unknown) as IterableParamsBuilder).iterateCasesWithSubcases(); +} + +/** + * Builder for combinatorial test **case** parameters. + * + * CaseParamsBuilder is immutable. Each method call returns a new, immutable object, + * modifying the list of cases according to the method called. + * + * This means, for example, that the `unit` passed into `TestBuilder.params()` can be reused. + */ +export class CaseParamsBuilder<CaseP extends {}> + extends ParamsBuilderBase<CaseP, {}> + implements Iterable<CaseP>, ParamsBuilder { + *iterateCasesWithSubcases(): CaseSubcaseIterable<CaseP, {}> { + for (const a of this.cases()) { + yield [a, undefined]; + } + } + + [Symbol.iterator](): Iterator<CaseP> { + return this.cases(); + } + + /** @inheritDoc */ + expandWithParams<NewP extends {}>( + expander: (_: Merged<{}, CaseP>) => Iterable<NewP> + ): CaseParamsBuilder<Merged<CaseP, NewP>> { + const newGenerator = expanderGenerator(this.cases, expander); + return new CaseParamsBuilder(() => newGenerator({})); + } + + /** @inheritDoc */ + expand<NewPKey extends string, NewPValue>( + key: NewPKey, + expander: (_: Merged<{}, CaseP>) => Iterable<NewPValue> + ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { + return this.expandWithParams(function* (p) { + for (const value of expander(p)) { + yield { [key]: value } as { readonly [name in NewPKey]: NewPValue }; + } + }); + } + + /** @inheritDoc */ + combineWithParams<NewP extends {}>( + newParams: Iterable<NewP> + ): CaseParamsBuilder<Merged<CaseP, NewP>> { + assertNotGenerator(newParams); + const seenValues = new Set<string>(); + for (const params of newParams) { + const paramsStr = stringifyPublicParams(params); + assert(!seenValues.has(paramsStr), `Duplicate entry in combine[WithParams]: ${paramsStr}`); + seenValues.add(paramsStr); + } + + return this.expandWithParams(() => newParams); + } + + /** @inheritDoc */ + combine<NewPKey extends string, NewPValue>( + key: NewPKey, + values: Iterable<NewPValue> + ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { + assertNotGenerator(values); + const mapped = mapLazy(values, v => ({ [key]: v } as { [name in NewPKey]: NewPValue })); + return this.combineWithParams(mapped); + } + + /** @inheritDoc */ + filter(pred: (_: Merged<{}, CaseP>) => boolean): CaseParamsBuilder<CaseP> { + const newGenerator = filterGenerator(this.cases, pred); + return new CaseParamsBuilder(() => newGenerator({})); + } + + /** @inheritDoc */ + unless(pred: (_: Merged<{}, CaseP>) => boolean): CaseParamsBuilder<CaseP> { + return this.filter(x => !pred(x)); + } + + /** + * "Finalize" the list of cases and begin defining subcases. + * Returns a new SubcaseParamsBuilder. Methods called on SubcaseParamsBuilder + * generate new subcases instead of new cases. + */ + beginSubcases(): SubcaseParamsBuilder<CaseP, {}> { + return new SubcaseParamsBuilder( + () => this.cases(), + function* () { + yield {}; + } + ); + } +} + +/** + * The unit CaseParamsBuilder, representing a single case with no params: `[ {} ]`. + * + * `punit` is passed to every `.params()`/`.paramsSubcasesOnly()` call, so `kUnitCaseParamsBuilder` + * is only explicitly needed if constructing a ParamsBuilder outside of a test builder. + */ +export const kUnitCaseParamsBuilder = new CaseParamsBuilder(function* () { + yield {}; +}); + +/** + * Builder for combinatorial test _subcase_ parameters. + * + * SubcaseParamsBuilder is immutable. Each method call returns a new, immutable object, + * modifying the list of subcases according to the method called. + */ +export class SubcaseParamsBuilder<CaseP extends {}, SubcaseP extends {}> + extends ParamsBuilderBase<CaseP, SubcaseP> + implements ParamsBuilder { + protected readonly subcases: (_: CaseP) => Generator<SubcaseP>; + + constructor(cases: () => Generator<CaseP>, generator: (_: CaseP) => Generator<SubcaseP>) { + super(cases); + this.subcases = generator; + } + + *iterateCasesWithSubcases(): CaseSubcaseIterable<CaseP, SubcaseP> { + for (const caseP of this.cases()) { + const subcases = Array.from(this.subcases(caseP)); + if (subcases.length) { + yield [caseP, subcases]; + } + } + } + + /** @inheritDoc */ + expandWithParams<NewP extends {}>( + expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewP> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { + return new SubcaseParamsBuilder(this.cases, expanderGenerator(this.subcases, expander)); + } + + /** @inheritDoc */ + expand<NewPKey extends string, NewPValue>( + key: NewPKey, + expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewPValue> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { + return this.expandWithParams(function* (p) { + for (const value of expander(p)) { + // TypeScript doesn't know here that NewPKey is always a single literal string type. + yield { [key]: value } as { [name in NewPKey]: NewPValue }; + } + }); + } + + /** @inheritDoc */ + combineWithParams<NewP extends {}>( + newParams: Iterable<NewP> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { + assertNotGenerator(newParams); + return this.expandWithParams(() => newParams); + } + + /** @inheritDoc */ + combine<NewPKey extends string, NewPValue>( + key: NewPKey, + values: Iterable<NewPValue> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { + assertNotGenerator(values); + return this.expand(key, () => values); + } + + /** @inheritDoc */ + filter(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { + return new SubcaseParamsBuilder(this.cases, filterGenerator(this.subcases, pred)); + } + + /** @inheritDoc */ + unless(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { + return this.filter(x => !pred(x)); + } +} + +function expanderGenerator<Base, A, B>( + baseGenerator: (_: Base) => Generator<A>, + expander: (_: Merged<Base, A>) => Iterable<B> +): (_: Base) => Generator<Merged<A, B>> { + return function* (base: Base) { + for (const a of baseGenerator(base)) { + for (const b of expander(mergeParams(base, a))) { + yield mergeParams(a, b); + } + } + }; +} + +function filterGenerator<Base, A>( + baseGenerator: (_: Base) => Generator<A>, + pred: (_: Merged<Base, A>) => boolean +): (_: Base) => Generator<A> { + return function* (base: Base) { + for (const a of baseGenerator(base)) { + if (pred(mergeParams(base, a))) { + yield a; + } + } + }; +} + +/** Assert an object is not a Generator (a thing returned from a generator function). */ +function assertNotGenerator(x: object) { + if ('constructor' in x) { + assert( + x.constructor !== (function* () {})().constructor, + 'Argument must not be a generator, as generators are not reusable' + ); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/resources.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/resources.ts new file mode 100644 index 0000000000..05451304b6 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/resources.ts @@ -0,0 +1,110 @@ +/** + * Base path for resources. The default value is correct for non-worker WPT, but standalone and + * workers must access resources using a different base path, so this is overridden in + * `test_worker-worker.ts` and `standalone.ts`. + */ +let baseResourcePath = './resources'; +let crossOriginHost = ''; + +function getAbsoluteBaseResourcePath(path: string) { + // Path is already an absolute one. + if (path[0] === '/') { + return path; + } + + // Path is relative + const relparts = window.location.pathname.split('/'); + relparts.pop(); + const pathparts = path.split('/'); + + let i; + for (i = 0; i < pathparts.length; ++i) { + switch (pathparts[i]) { + case '': + break; + case '.': + break; + case '..': + relparts.pop(); + break; + default: + relparts.push(pathparts[i]); + break; + } + } + + return relparts.join('/'); +} + +function runningOnLocalHost(): boolean { + const hostname = window.location.hostname; + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +/** + * Get a path to a resource in the `resources` directory relative to the current execution context + * (html file or worker .js file), for `fetch()`, `<img>`, `<video>`, etc but from cross origin host. + * Provide onlineUrl if the case running online. + * @internal MAINTENANCE_TODO: Cases may run in the LAN environment (not localhost but no internet + * access). We temporarily use `crossOriginHost` to configure the cross origin host name in that situation. + * But opening to auto-detect mechanism or other solutions. + */ +export function getCrossOriginResourcePath(pathRelativeToResourcesDir: string, onlineUrl = '') { + // A cross origin host has been configured. Use this to load resource. + if (crossOriginHost !== '') { + return ( + crossOriginHost + + getAbsoluteBaseResourcePath(baseResourcePath) + + '/' + + pathRelativeToResourcesDir + ); + } + + // Using 'localhost' and '127.0.0.1' trick to load cross origin resource. Set cross origin host name + // to 'localhost' if case is not running in 'localhost' domain. Otherwise, use '127.0.0.1'. + // host name to locahost unless the server running in + if (runningOnLocalHost()) { + let crossOriginHostName = ''; + if (location.hostname === 'localhost') { + crossOriginHostName = 'http://127.0.0.1'; + } else { + crossOriginHostName = 'http://localhost'; + } + + return ( + crossOriginHostName + + ':' + + location.port + + getAbsoluteBaseResourcePath(baseResourcePath) + + '/' + + pathRelativeToResourcesDir + ); + } + + return onlineUrl; +} + +/** + * Get a path to a resource in the `resources` directory, relative to the current execution context + * (html file or worker .js file), for `fetch()`, `<img>`, `<video>`, etc. Pass the cross origin host + * name if wants to load resoruce from cross origin host. + */ +export function getResourcePath(pathRelativeToResourcesDir: string) { + return baseResourcePath + '/' + pathRelativeToResourcesDir; +} + +/** + * Set the base resource path (path to the `resources` directory relative to the current + * execution context). + */ +export function setBaseResourcePath(path: string) { + baseResourcePath = path; +} + +/** + * Set the cross origin host and cases related to cross origin + * will load resource from the given host. + */ +export function setCrossOriginHost(host: string) { + crossOriginHost = host; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/test_config.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/test_config.ts new file mode 100644 index 0000000000..bec74e20c5 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/test_config.ts @@ -0,0 +1,20 @@ +export type TestConfig = { + maxSubcasesInFlight: number; + testHeartbeatCallback: () => void; + noRaceWithRejectOnTimeout: boolean; + + /** + * Controls the emission of loops in constant-evaluation shaders under + * 'webgpu:shader,execution,expression,*' + * FXC is extremely slow to compile shaders with loops unrolled, where as the + * MSL compiler is extremely slow to compile with loops rolled. + */ + unrollConstEvalLoops: boolean; +}; + +export const globalTestConfig: TestConfig = { + maxSubcasesInFlight: 500, + testHeartbeatCallback: () => {}, + noRaceWithRejectOnTimeout: false, + unrollConstEvalLoops: false, +}; diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/test_group.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/test_group.ts new file mode 100644 index 0000000000..5b761db9db --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/test_group.ts @@ -0,0 +1 @@ +export { makeTestGroup } from '../internal/test_group.js'; diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/file_loader.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/file_loader.ts new file mode 100644 index 0000000000..922d6a09dd --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/file_loader.ts @@ -0,0 +1,95 @@ +import { IterableTestGroup } from '../internal/test_group.js'; +import { assert } from '../util/util.js'; + +import { parseQuery } from './query/parseQuery.js'; +import { TestQuery } from './query/query.js'; +import { TestSuiteListing } from './test_suite_listing.js'; +import { loadTreeForQuery, TestTree, TestTreeLeaf } from './tree.js'; + +// A listing file, e.g. either of: +// - `src/webgpu/listing.ts` (which is dynamically computed, has a Promise<TestSuiteListing>) +// - `out/webgpu/listing.js` (which is pre-baked, has a TestSuiteListing) +interface ListingFile { + listing: Promise<TestSuiteListing> | TestSuiteListing; +} + +// A .spec.ts file, as imported. +export interface SpecFile { + readonly description: string; + readonly g: IterableTestGroup; +} + +export interface ImportInfo { + url: string; +} + +interface TestFileLoaderEventMap { + import: MessageEvent<ImportInfo>; + finish: MessageEvent<void>; +} + +export interface TestFileLoader extends EventTarget { + addEventListener<K extends keyof TestFileLoaderEventMap>( + type: K, + listener: (this: TestFileLoader, ev: TestFileLoaderEventMap[K]) => void, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener<K extends keyof TestFileLoaderEventMap>( + type: K, + listener: (this: TestFileLoader, ev: TestFileLoaderEventMap[K]) => void, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; +} + +// Base class for DefaultTestFileLoader and FakeTestFileLoader. +export abstract class TestFileLoader extends EventTarget { + abstract listing(suite: string): Promise<TestSuiteListing>; + protected abstract import(path: string): Promise<SpecFile>; + + importSpecFile(suite: string, path: string[]): Promise<SpecFile> { + const url = `${suite}/${path.join('/')}.spec.js`; + this.dispatchEvent( + new MessageEvent<ImportInfo>('import', { data: { url } }) + ); + return this.import(url); + } + + async loadTree(query: TestQuery, subqueriesToExpand: string[] = []): Promise<TestTree> { + const tree = await loadTreeForQuery( + this, + query, + subqueriesToExpand.map(s => { + const q = parseQuery(s); + assert(q.level >= 2, () => `subqueriesToExpand entries should not be multi-file:\n ${q}`); + return q; + }) + ); + this.dispatchEvent(new MessageEvent<void>('finish')); + return tree; + } + + async loadCases(query: TestQuery): Promise<IterableIterator<TestTreeLeaf>> { + const tree = await this.loadTree(query); + return tree.iterateLeaves(); + } +} + +export class DefaultTestFileLoader extends TestFileLoader { + async listing(suite: string): Promise<TestSuiteListing> { + return ((await import(`../../${suite}/listing.js`)) as ListingFile).listing; + } + + import(path: string): Promise<SpecFile> { + return import(`../../${path}`); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/logging/log_message.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/log_message.ts new file mode 100644 index 0000000000..ee006cdeb3 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/log_message.ts @@ -0,0 +1,44 @@ +import { ErrorWithExtra } from '../../util/util.js'; +import { extractImportantStackTrace } from '../stack.js'; + +export class LogMessageWithStack extends Error { + readonly extra: unknown; + + private stackHiddenMessage: string | undefined = undefined; + + constructor(name: string, ex: Error | ErrorWithExtra) { + super(ex.message); + + this.name = name; + this.stack = ex.stack; + if ('extra' in ex) { + this.extra = ex.extra; + } + } + + /** Set a flag so the stack is not printed in toJSON(). */ + setStackHidden(stackHiddenMessage: string) { + this.stackHiddenMessage ??= stackHiddenMessage; + } + + toJSON(): string { + let m = this.name; + if (this.message) m += ': ' + this.message; + if (this.stack) { + if (this.stackHiddenMessage === undefined) { + m += '\n' + extractImportantStackTrace(this); + } else if (this.stackHiddenMessage) { + m += `\n at (elided: ${this.stackHiddenMessage})`; + } + } + return m; + } +} + +/** + * Returns a string, nicely indented, for debug logs. + * This is used in the cmdline and wpt runtimes. In WPT, it shows up in the `*-actual.txt` file. + */ +export function prettyPrintLog(log: LogMessageWithStack): string { + return ' - ' + log.toJSON().replace(/\n/g, '\n '); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/logging/logger.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/logger.ts new file mode 100644 index 0000000000..e4526cff54 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/logger.ts @@ -0,0 +1,30 @@ +import { version } from '../version.js'; + +import { LiveTestCaseResult } from './result.js'; +import { TestCaseRecorder } from './test_case_recorder.js'; + +export type LogResults = Map<string, LiveTestCaseResult>; + +export class Logger { + static globalDebugMode: boolean = false; + + readonly overriddenDebugMode: boolean | undefined; + readonly results: LogResults = new Map(); + + constructor({ overrideDebugMode }: { overrideDebugMode?: boolean } = {}) { + this.overriddenDebugMode = overrideDebugMode; + } + + record(name: string): [TestCaseRecorder, LiveTestCaseResult] { + const result: LiveTestCaseResult = { status: 'running', timems: -1 }; + this.results.set(name, result); + return [ + new TestCaseRecorder(result, this.overriddenDebugMode ?? Logger.globalDebugMode), + result, + ]; + } + + asJSON(space?: number): string { + return JSON.stringify({ version, results: Array.from(this.results) }, undefined, space); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/logging/result.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/result.ts new file mode 100644 index 0000000000..0de661b50c --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/result.ts @@ -0,0 +1,21 @@ +import { LogMessageWithStack } from './log_message.js'; + +// MAINTENANCE_TODO: Add warn expectations +export type Expectation = 'pass' | 'skip' | 'fail'; + +export type Status = 'running' | 'warn' | Expectation; + +export interface TestCaseResult { + status: Status; + timems: number; +} + +export interface LiveTestCaseResult extends TestCaseResult { + logs?: LogMessageWithStack[]; +} + +export interface TransferredTestCaseResult extends TestCaseResult { + // When transferred from a worker, a LogMessageWithStack turns into a generic Error + // (its prototype gets lost and replaced with Error). + logs?: Error[]; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/logging/test_case_recorder.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/test_case_recorder.ts new file mode 100644 index 0000000000..7507bbdec6 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/logging/test_case_recorder.ts @@ -0,0 +1,158 @@ +import { SkipTestCase, UnexpectedPassError } from '../../framework/fixture.js'; +import { globalTestConfig } from '../../framework/test_config.js'; +import { now, assert } from '../../util/util.js'; + +import { LogMessageWithStack } from './log_message.js'; +import { Expectation, LiveTestCaseResult } from './result.js'; + +enum LogSeverity { + Pass = 0, + Skip = 1, + Warn = 2, + ExpectFailed = 3, + ValidationFailed = 4, + ThrewException = 5, +} + +const kMaxLogStacks = 2; +const kMinSeverityForStack = LogSeverity.Warn; + +/** Holds onto a LiveTestCaseResult owned by the Logger, and writes the results into it. */ +export class TestCaseRecorder { + private result: LiveTestCaseResult; + private inSubCase: boolean = false; + private subCaseStatus = LogSeverity.Pass; + private finalCaseStatus = LogSeverity.Pass; + private hideStacksBelowSeverity = kMinSeverityForStack; + private startTime = -1; + private logs: LogMessageWithStack[] = []; + private logLinesAtCurrentSeverity = 0; + private debugging = false; + /** Used to dedup log messages which have identical stacks. */ + private messagesForPreviouslySeenStacks = new Map<string, LogMessageWithStack>(); + + constructor(result: LiveTestCaseResult, debugging: boolean) { + this.result = result; + this.debugging = debugging; + } + + start(): void { + assert(this.startTime < 0, 'TestCaseRecorder cannot be reused'); + this.startTime = now(); + } + + finish(): void { + assert(this.startTime >= 0, 'finish() before start()'); + + const timeMilliseconds = now() - this.startTime; + // Round to next microsecond to avoid storing useless .xxxx00000000000002 in results. + this.result.timems = Math.ceil(timeMilliseconds * 1000) / 1000; + + // Convert numeric enum back to string (but expose 'exception' as 'fail') + this.result.status = + this.finalCaseStatus === LogSeverity.Pass + ? 'pass' + : this.finalCaseStatus === LogSeverity.Skip + ? 'skip' + : this.finalCaseStatus === LogSeverity.Warn + ? 'warn' + : 'fail'; // Everything else is an error + + this.result.logs = this.logs; + } + + beginSubCase() { + this.subCaseStatus = LogSeverity.Pass; + this.inSubCase = true; + } + + endSubCase(expectedStatus: Expectation) { + try { + if (expectedStatus === 'fail') { + if (this.subCaseStatus <= LogSeverity.Warn) { + throw new UnexpectedPassError(); + } else { + this.subCaseStatus = LogSeverity.Pass; + } + } + } finally { + this.inSubCase = false; + if (this.subCaseStatus > this.finalCaseStatus) { + this.finalCaseStatus = this.subCaseStatus; + } + } + } + + injectResult(injectedResult: LiveTestCaseResult): void { + Object.assign(this.result, injectedResult); + } + + debug(ex: Error): void { + if (!this.debugging) return; + this.logImpl(LogSeverity.Pass, 'DEBUG', ex); + } + + info(ex: Error): void { + this.logImpl(LogSeverity.Pass, 'INFO', ex); + } + + skipped(ex: SkipTestCase): void { + this.logImpl(LogSeverity.Skip, 'SKIP', ex); + } + + warn(ex: Error): void { + this.logImpl(LogSeverity.Warn, 'WARN', ex); + } + + expectationFailed(ex: Error): void { + this.logImpl(LogSeverity.ExpectFailed, 'EXPECTATION FAILED', ex); + } + + validationFailed(ex: Error): void { + this.logImpl(LogSeverity.ValidationFailed, 'VALIDATION FAILED', ex); + } + + threw(ex: unknown): void { + if (ex instanceof SkipTestCase) { + this.skipped(ex); + return; + } + this.logImpl(LogSeverity.ThrewException, 'EXCEPTION', ex); + } + + private logImpl(level: LogSeverity, name: string, baseException: unknown): void { + assert(baseException instanceof Error, 'test threw a non-Error object'); + globalTestConfig.testHeartbeatCallback(); + const logMessage = new LogMessageWithStack(name, baseException); + + // Final case status should be the "worst" of all log entries. + if (this.inSubCase) { + if (level > this.subCaseStatus) this.subCaseStatus = level; + } else { + if (level > this.finalCaseStatus) this.finalCaseStatus = level; + } + + // setFirstLineOnly for all logs except `kMaxLogStacks` stacks at the highest severity + if (level > this.hideStacksBelowSeverity) { + this.logLinesAtCurrentSeverity = 0; + this.hideStacksBelowSeverity = level; + + // Go back and setFirstLineOnly for everything of a lower log level + for (const log of this.logs) { + log.setStackHidden('below max severity'); + } + } + if (level === this.hideStacksBelowSeverity) { + this.logLinesAtCurrentSeverity++; + } else if (level < kMinSeverityForStack) { + logMessage.setStackHidden(''); + } else if (level < this.hideStacksBelowSeverity) { + logMessage.setStackHidden('below max severity'); + } + if (this.logLinesAtCurrentSeverity > kMaxLogStacks) { + logMessage.setStackHidden(`only ${kMaxLogStacks} shown`); + } + + this.logs.push(logMessage); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/params_utils.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/params_utils.ts new file mode 100644 index 0000000000..07d2f836f1 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/params_utils.ts @@ -0,0 +1,124 @@ +import { TestParams } from '../framework/fixture.js'; +import { ResolveType, UnionToIntersection } from '../util/types.js'; +import { assert } from '../util/util.js'; + +import { comparePublicParamsPaths, Ordering } from './query/compare.js'; +import { kWildcard, kParamSeparator, kParamKVSeparator } from './query/separators.js'; + +export type JSONWithUndefined = + | undefined + | null + | number + | string + | boolean + | readonly JSONWithUndefined[] + // Ideally this would recurse into JSONWithUndefined, but it breaks code. + | { readonly [k: string]: unknown }; +export interface TestParamsRW { + [k: string]: JSONWithUndefined; +} +export type TestParamsIterable = Iterable<TestParams>; + +export function paramKeyIsPublic(key: string): boolean { + return !key.startsWith('_'); +} + +export function extractPublicParams(params: TestParams): TestParams { + const publicParams: TestParamsRW = {}; + for (const k of Object.keys(params)) { + if (paramKeyIsPublic(k)) { + publicParams[k] = params[k]; + } + } + return publicParams; +} + +export const badParamValueChars = new RegExp( + '[' + kParamKVSeparator + kParamSeparator + kWildcard + ']' +); + +export function publicParamsEquals(x: TestParams, y: TestParams): boolean { + return comparePublicParamsPaths(x, y) === Ordering.Equal; +} + +export type KeyOfNeverable<T> = T extends never ? never : keyof T; +export type AllKeysFromUnion<T> = keyof T | KeyOfNeverable<UnionToIntersection<T>>; +export type KeyOfOr<T, K, Default> = K extends keyof T ? T[K] : Default; + +/** + * Flatten a union of interfaces into a single interface encoding the same type. + * + * Flattens a union in such a way that: + * `{ a: number, b?: undefined } | { b: string, a?: undefined }` + * (which is the value type of `[{ a: 1 }, { b: 1 }]`) + * becomes `{ a: number | undefined, b: string | undefined }`. + * + * And also works for `{ a: number } | { b: string }` which maps to the same. + */ +export type FlattenUnionOfInterfaces<T> = { + [K in AllKeysFromUnion<T>]: KeyOfOr< + T, + // If T always has K, just take T[K] (union of C[K] for each component C of T): + K, + // Otherwise, take the union of C[K] for each component C of T, PLUS undefined: + undefined | KeyOfOr<UnionToIntersection<T>, K, void> + >; +}; + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +function typeAssert<T extends 'pass'>() {} +{ + type Test<T, U> = [T] extends [U] + ? [U] extends [T] + ? 'pass' + : { actual: ResolveType<T>; expected: U } + : { actual: ResolveType<T>; expected: U }; + + type T01 = { a: number } | { b: string }; + type T02 = { a: number } | { b?: string }; + type T03 = { a: number } | { a?: number }; + type T04 = { a: number } | { a: string }; + type T05 = { a: number } | { a?: string }; + + type T11 = { a: number; b?: undefined } | { a?: undefined; b: string }; + + type T21 = { a: number; b?: undefined } | { b: string }; + type T22 = { a: number; b?: undefined } | { b?: string }; + type T23 = { a: number; b?: undefined } | { a?: number }; + type T24 = { a: number; b?: undefined } | { a: string }; + type T25 = { a: number; b?: undefined } | { a?: string }; + type T26 = { a: number; b?: undefined } | { a: undefined }; + type T27 = { a: number; b?: undefined } | { a: undefined; b: undefined }; + + /* prettier-ignore */ { + typeAssert<Test<FlattenUnionOfInterfaces<T01>, { a: number | undefined; b: string | undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T02>, { a: number | undefined; b: string | undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T03>, { a: number | undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T04>, { a: number | string }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T05>, { a: number | string | undefined }>>(); + + typeAssert<Test<FlattenUnionOfInterfaces<T11>, { a: number | undefined; b: string | undefined }>>(); + + typeAssert<Test<FlattenUnionOfInterfaces<T22>, { a: number | undefined; b: string | undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T23>, { a: number | undefined; b: undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T24>, { a: number | string; b: undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T25>, { a: number | string | undefined; b: undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T27>, { a: number | undefined; b: undefined }>>(); + + // Unexpected test results - hopefully okay to ignore these + typeAssert<Test<FlattenUnionOfInterfaces<T21>, { b: string | undefined }>>(); + typeAssert<Test<FlattenUnionOfInterfaces<T26>, { a: number | undefined }>>(); + } +} + +export type Merged<A, B> = MergedFromFlat<A, FlattenUnionOfInterfaces<B>>; +export type MergedFromFlat<A, B> = { + [K in keyof A | keyof B]: K extends keyof B ? B[K] : K extends keyof A ? A[K] : never; +}; + +export function mergeParams<A extends {}, B extends {}>(a: A, b: B): Merged<A, B> { + for (const key of Object.keys(a)) { + assert(!(key in b), 'Duplicate key: ' + key); + } + return { ...a, ...b } as Merged<A, B>; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/compare.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/compare.ts new file mode 100644 index 0000000000..e9f4b01503 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/compare.ts @@ -0,0 +1,94 @@ +import { TestParams } from '../../framework/fixture.js'; +import { assert, objectEquals } from '../../util/util.js'; +import { paramKeyIsPublic } from '../params_utils.js'; + +import { TestQuery } from './query.js'; + +export const enum Ordering { + Unordered, + StrictSuperset, + Equal, + StrictSubset, +} + +/** + * Compares two queries for their ordering (which is used to build the tree). + * + * See src/unittests/query_compare.spec.ts for examples. + */ +export function compareQueries(a: TestQuery, b: TestQuery): Ordering { + if (a.suite !== b.suite) { + return Ordering.Unordered; + } + + const filePathOrdering = comparePaths(a.filePathParts, b.filePathParts); + if (filePathOrdering !== Ordering.Equal || a.isMultiFile || b.isMultiFile) { + return compareOneLevel(filePathOrdering, a.isMultiFile, b.isMultiFile); + } + assert('testPathParts' in a && 'testPathParts' in b); + + const testPathOrdering = comparePaths(a.testPathParts, b.testPathParts); + if (testPathOrdering !== Ordering.Equal || a.isMultiTest || b.isMultiTest) { + return compareOneLevel(testPathOrdering, a.isMultiTest, b.isMultiTest); + } + assert('params' in a && 'params' in b); + + const paramsPathOrdering = comparePublicParamsPaths(a.params, b.params); + if (paramsPathOrdering !== Ordering.Equal || a.isMultiCase || b.isMultiCase) { + return compareOneLevel(paramsPathOrdering, a.isMultiCase, b.isMultiCase); + } + return Ordering.Equal; +} + +/** + * Compares a single level of a query. + * + * "IsBig" means the query is big relative to the level, e.g. for test-level: + * - Anything >= `suite:a,*` is big + * - Anything <= `suite:a:*` is small + */ +function compareOneLevel(ordering: Ordering, aIsBig: boolean, bIsBig: boolean): Ordering { + assert(ordering !== Ordering.Equal || aIsBig || bIsBig); + if (ordering === Ordering.Unordered) return Ordering.Unordered; + if (aIsBig && bIsBig) return ordering; + if (!aIsBig && !bIsBig) return Ordering.Unordered; // Equal case is already handled + // Exactly one of (a, b) is big. + if (aIsBig && ordering !== Ordering.StrictSubset) return Ordering.StrictSuperset; + if (bIsBig && ordering !== Ordering.StrictSuperset) return Ordering.StrictSubset; + return Ordering.Unordered; +} + +function comparePaths(a: readonly string[], b: readonly string[]): Ordering { + const shorter = Math.min(a.length, b.length); + + for (let i = 0; i < shorter; ++i) { + if (a[i] !== b[i]) { + return Ordering.Unordered; + } + } + if (a.length === b.length) { + return Ordering.Equal; + } else if (a.length < b.length) { + return Ordering.StrictSuperset; + } else { + return Ordering.StrictSubset; + } +} + +export function comparePublicParamsPaths(a: TestParams, b: TestParams): Ordering { + const aKeys = Object.keys(a).filter(k => paramKeyIsPublic(k)); + const commonKeys = new Set(aKeys.filter(k => k in b)); + + for (const k of commonKeys) { + if (!objectEquals(a[k], b[k])) { + return Ordering.Unordered; + } + } + const bKeys = Object.keys(b).filter(k => paramKeyIsPublic(k)); + const aRemainingKeys = aKeys.length - commonKeys.size; + const bRemainingKeys = bKeys.length - commonKeys.size; + if (aRemainingKeys === 0 && bRemainingKeys === 0) return Ordering.Equal; + if (aRemainingKeys === 0) return Ordering.StrictSuperset; + if (bRemainingKeys === 0) return Ordering.StrictSubset; + return Ordering.Unordered; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/encode_selectively.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/encode_selectively.ts new file mode 100644 index 0000000000..ab1997b6e4 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/encode_selectively.ts @@ -0,0 +1,23 @@ +/** + * Encodes a stringified TestQuery so that it can be placed in a `?q=` parameter in a URL. + * + * `encodeURIComponent` encodes in accordance with `application/x-www-form-urlencoded`, + * but URLs don't actually have to be as strict as HTML form encoding + * (we interpret this purely from JavaScript). + * So we encode the component, then selectively convert some %-encoded escape codes + * back to their original form for readability/copyability. + */ +export function encodeURIComponentSelectively(s: string): string { + let ret = encodeURIComponent(s); + ret = ret.replace(/%22/g, '"'); // for JSON strings + ret = ret.replace(/%2C/g, ','); // for path separator, and JSON arrays + ret = ret.replace(/%3A/g, ':'); // for big separator + ret = ret.replace(/%3B/g, ';'); // for param separator + ret = ret.replace(/%3D/g, '='); // for params (k=v) + ret = ret.replace(/%5B/g, '['); // for JSON arrays + ret = ret.replace(/%5D/g, ']'); // for JSON arrays + ret = ret.replace(/%7B/g, '{'); // for JSON objects + ret = ret.replace(/%7D/g, '}'); // for JSON objects + ret = ret.replace(/%E2%9C%97/g, '✗'); // for jsUndefinedMagicValue + return ret; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/json_param_value.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/json_param_value.ts new file mode 100644 index 0000000000..f4be7642d3 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/json_param_value.ts @@ -0,0 +1,83 @@ +import { assert, sortObjectByKey } from '../../util/util.js'; +import { JSONWithUndefined } from '../params_utils.js'; + +// JSON can't represent various values and by default stores them as `null`. +// Instead, storing them as a magic string values in JSON. +const jsUndefinedMagicValue = '_undef_'; +const jsNaNMagicValue = '_nan_'; +const jsPositiveInfinityMagicValue = '_posinfinity_'; +const jsNegativeInfinityMagicValue = '_neginfinity_'; + +// -0 needs to be handled separately, because -0 === +0 returns true. Not +// special casing +0/0, since it behaves intuitively. Assuming that if -0 is +// being used, the differentiation from +0 is desired. +const jsNegativeZeroMagicValue = '_negzero_'; + +const toStringMagicValue = new Map<unknown, string>([ + [undefined, jsUndefinedMagicValue], + [NaN, jsNaNMagicValue], + [Number.POSITIVE_INFINITY, jsPositiveInfinityMagicValue], + [Number.NEGATIVE_INFINITY, jsNegativeInfinityMagicValue], + // No -0 handling because it is special cased. +]); + +const fromStringMagicValue = new Map<string, unknown>([ + [jsUndefinedMagicValue, undefined], + [jsNaNMagicValue, NaN], + [jsPositiveInfinityMagicValue, Number.POSITIVE_INFINITY], + [jsNegativeInfinityMagicValue, Number.NEGATIVE_INFINITY], + // -0 is handled in this direction because there is no comparison issue. + [jsNegativeZeroMagicValue, -0], +]); + +function stringifyFilter(k: string, v: unknown): unknown { + // Make sure no one actually uses a magic value as a parameter. + if (typeof v === 'string') { + assert( + !fromStringMagicValue.has(v), + `${v} is a magic value for stringification, so cannot be used` + ); + + assert( + v !== jsNegativeZeroMagicValue, + `${v} is a magic value for stringification, so cannot be used` + ); + } + + if (Object.is(v, -0)) { + return jsNegativeZeroMagicValue; + } + + return toStringMagicValue.has(v) ? toStringMagicValue.get(v) : v; +} + +export function stringifyParamValue(value: JSONWithUndefined): string { + return JSON.stringify(value, stringifyFilter); +} + +/** + * Like stringifyParamValue but sorts dictionaries by key, for hashing. + */ +export function stringifyParamValueUniquely(value: JSONWithUndefined): string { + return JSON.stringify(value, (k, v) => { + if (typeof v === 'object' && v !== null) { + return sortObjectByKey(v); + } + + return stringifyFilter(k, v); + }); +} + +// 'any' is part of the JSON.parse reviver interface, so cannot be avoided. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseParamValueReviver(k: string, v: any): any { + if (fromStringMagicValue.has(v)) { + return fromStringMagicValue.get(v); + } + + return v; +} + +export function parseParamValue(s: string): JSONWithUndefined { + return JSON.parse(s, parseParamValueReviver); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/parseQuery.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/parseQuery.ts new file mode 100644 index 0000000000..996835b0ec --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/parseQuery.ts @@ -0,0 +1,155 @@ +import { assert } from '../../util/util.js'; +import { + TestParamsRW, + JSONWithUndefined, + badParamValueChars, + paramKeyIsPublic, +} from '../params_utils.js'; + +import { parseParamValue } from './json_param_value.js'; +import { + TestQuery, + TestQueryMultiFile, + TestQueryMultiTest, + TestQueryMultiCase, + TestQuerySingleCase, +} from './query.js'; +import { kBigSeparator, kWildcard, kPathSeparator, kParamSeparator } from './separators.js'; +import { validQueryPart } from './validQueryPart.js'; + +export function parseQuery(s: string): TestQuery { + try { + return parseQueryImpl(s); + } catch (ex) { + if (ex instanceof Error) { + ex.message += '\n on: ' + s; + } + throw ex; + } +} + +function parseQueryImpl(s: string): TestQuery { + // Undo encodeURIComponentSelectively + s = decodeURIComponent(s); + + // bigParts are: suite, file, test, params (note kBigSeparator could appear in params) + let suite: string; + let fileString: string | undefined; + let testString: string | undefined; + let paramsString: string | undefined; + { + const i1 = s.indexOf(kBigSeparator); + assert(i1 !== -1, `query string must have at least one ${kBigSeparator}`); + suite = s.substring(0, i1); + const i2 = s.indexOf(kBigSeparator, i1 + 1); + if (i2 === -1) { + fileString = s.substring(i1 + 1); + } else { + fileString = s.substring(i1 + 1, i2); + const i3 = s.indexOf(kBigSeparator, i2 + 1); + if (i3 === -1) { + testString = s.substring(i2 + 1); + } else { + testString = s.substring(i2 + 1, i3); + paramsString = s.substring(i3 + 1); + } + } + } + + const { parts: file, wildcard: filePathHasWildcard } = parseBigPart(fileString, kPathSeparator); + + if (testString === undefined) { + // Query is file-level + assert( + filePathHasWildcard, + `File-level query without wildcard ${kWildcard}. Did you want a file-level query \ +(append ${kPathSeparator}${kWildcard}) or test-level query (append ${kBigSeparator}${kWildcard})?` + ); + return new TestQueryMultiFile(suite, file); + } + assert(!filePathHasWildcard, `Wildcard ${kWildcard} must be at the end of the query string`); + + const { parts: test, wildcard: testPathHasWildcard } = parseBigPart(testString, kPathSeparator); + + if (paramsString === undefined) { + // Query is test-level + assert( + testPathHasWildcard, + `Test-level query without wildcard ${kWildcard}; did you want a test-level query \ +(append ${kPathSeparator}${kWildcard}) or case-level query (append ${kBigSeparator}${kWildcard})?` + ); + assert(file.length > 0, 'File part of test-level query was empty (::)'); + return new TestQueryMultiTest(suite, file, test); + } + + // Query is case-level + assert(!testPathHasWildcard, `Wildcard ${kWildcard} must be at the end of the query string`); + + const { parts: paramsParts, wildcard: paramsHasWildcard } = parseBigPart( + paramsString, + kParamSeparator + ); + + assert(test.length > 0, 'Test part of case-level query was empty (::)'); + + const params: TestParamsRW = {}; + for (const paramPart of paramsParts) { + const [k, v] = parseSingleParam(paramPart); + assert(validQueryPart.test(k), `param key names must match ${validQueryPart}`); + params[k] = v; + } + if (paramsHasWildcard) { + return new TestQueryMultiCase(suite, file, test, params); + } else { + return new TestQuerySingleCase(suite, file, test, params); + } +} + +// webgpu:a,b,* or webgpu:a,b,c:* +const kExampleQueries = `\ +webgpu${kBigSeparator}a${kPathSeparator}b${kPathSeparator}${kWildcard} or \ +webgpu${kBigSeparator}a${kPathSeparator}b${kPathSeparator}c${kBigSeparator}${kWildcard}`; + +function parseBigPart( + s: string, + separator: typeof kParamSeparator | typeof kPathSeparator +): { parts: string[]; wildcard: boolean } { + if (s === '') { + return { parts: [], wildcard: false }; + } + const parts = s.split(separator); + + let endsWithWildcard = false; + for (const [i, part] of parts.entries()) { + if (i === parts.length - 1) { + endsWithWildcard = part === kWildcard; + } + assert( + part.indexOf(kWildcard) === -1 || endsWithWildcard, + `Wildcard ${kWildcard} must be complete last part of a path (e.g. ${kExampleQueries})` + ); + } + if (endsWithWildcard) { + // Remove the last element of the array (which is just the wildcard). + parts.length = parts.length - 1; + } + return { parts, wildcard: endsWithWildcard }; +} + +function parseSingleParam(paramSubstring: string): [string, JSONWithUndefined] { + assert(paramSubstring !== '', 'Param in a query must not be blank (is there a trailing comma?)'); + const i = paramSubstring.indexOf('='); + assert(i !== -1, 'Param in a query must be of form key=value'); + const k = paramSubstring.substring(0, i); + assert(paramKeyIsPublic(k), 'Param in a query must not be private (start with _)'); + const v = paramSubstring.substring(i + 1); + return [k, parseSingleParamValue(v)]; +} + +function parseSingleParamValue(s: string): JSONWithUndefined { + assert( + !badParamValueChars.test(s), + `param value must not match ${badParamValueChars} - was ${s}` + ); + return parseParamValue(s); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/query.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/query.ts new file mode 100644 index 0000000000..59e96cb538 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/query.ts @@ -0,0 +1,262 @@ +import { TestParams } from '../../framework/fixture.js'; +import { optionEnabled } from '../../runtime/helper/options.js'; +import { assert, unreachable } from '../../util/util.js'; +import { Expectation } from '../logging/result.js'; + +import { compareQueries, Ordering } from './compare.js'; +import { encodeURIComponentSelectively } from './encode_selectively.js'; +import { parseQuery } from './parseQuery.js'; +import { kBigSeparator, kPathSeparator, kWildcard } from './separators.js'; +import { stringifyPublicParams } from './stringify_params.js'; + +/** + * Represents a test query of some level. + * + * TestQuery types are immutable. + */ +export type TestQuery = + | TestQuerySingleCase + | TestQueryMultiCase + | TestQueryMultiTest + | TestQueryMultiFile; + +/** + * - 1 = MultiFile. + * - 2 = MultiTest. + * - 3 = MultiCase. + * - 4 = SingleCase. + */ +export type TestQueryLevel = 1 | 2 | 3 | 4; + +export interface TestQueryWithExpectation { + query: TestQuery; + expectation: Expectation; +} + +/** + * A multi-file test query, like `s:*` or `s:a,b,*`. + * + * Immutable (makes copies of constructor args). + */ +export class TestQueryMultiFile { + readonly level: TestQueryLevel = 1; + readonly isMultiFile: boolean = true; + readonly suite: string; + readonly filePathParts: readonly string[]; + + constructor(suite: string, file: readonly string[]) { + this.suite = suite; + this.filePathParts = [...file]; + } + + get depthInLevel() { + return this.filePathParts.length; + } + + toString(): string { + return encodeURIComponentSelectively(this.toStringHelper().join(kBigSeparator)); + } + + protected toStringHelper(): string[] { + return [this.suite, [...this.filePathParts, kWildcard].join(kPathSeparator)]; + } +} + +/** + * A multi-test test query, like `s:f:*` or `s:f:a,b,*`. + * + * Immutable (makes copies of constructor args). + */ +export class TestQueryMultiTest extends TestQueryMultiFile { + readonly level: TestQueryLevel = 2; + readonly isMultiFile: false = false; + readonly isMultiTest: boolean = true; + readonly testPathParts: readonly string[]; + + constructor(suite: string, file: readonly string[], test: readonly string[]) { + super(suite, file); + assert(file.length > 0, 'multi-test (or finer) query must have file-path'); + this.testPathParts = [...test]; + } + + get depthInLevel() { + return this.testPathParts.length; + } + + protected toStringHelper(): string[] { + return [ + this.suite, + this.filePathParts.join(kPathSeparator), + [...this.testPathParts, kWildcard].join(kPathSeparator), + ]; + } +} + +/** + * A multi-case test query, like `s:f:t:*` or `s:f:t:a,b,*`. + * + * Immutable (makes copies of constructor args), except for param values + * (which aren't normally supposed to change; they're marked readonly in TestParams). + */ +export class TestQueryMultiCase extends TestQueryMultiTest { + readonly level: TestQueryLevel = 3; + readonly isMultiTest: false = false; + readonly isMultiCase: boolean = true; + readonly params: TestParams; + + constructor(suite: string, file: readonly string[], test: readonly string[], params: TestParams) { + super(suite, file, test); + assert(test.length > 0, 'multi-case (or finer) query must have test-path'); + this.params = { ...params }; + } + + get depthInLevel() { + return Object.keys(this.params).length; + } + + protected toStringHelper(): string[] { + return [ + this.suite, + this.filePathParts.join(kPathSeparator), + this.testPathParts.join(kPathSeparator), + stringifyPublicParams(this.params, true), + ]; + } +} + +/** + * A multi-case test query, like `s:f:t:` or `s:f:t:a=1,b=1`. + * + * Immutable (makes copies of constructor args). + */ +export class TestQuerySingleCase extends TestQueryMultiCase { + readonly level: TestQueryLevel = 4; + readonly isMultiCase: false = false; + + get depthInLevel() { + return 0; + } + + protected toStringHelper(): string[] { + return [ + this.suite, + this.filePathParts.join(kPathSeparator), + this.testPathParts.join(kPathSeparator), + stringifyPublicParams(this.params), + ]; + } +} + +/** + * Parse raw expectations input into TestQueryWithExpectation[], filtering so that only + * expectations that are relevant for the provided query and wptURL. + * + * `rawExpectations` should be @type {{ query: string, expectation: Expectation }[]} + * + * The `rawExpectations` are parsed and validated that they are in the correct format. + * If `wptURL` is passed, the query string should be of the full path format such + * as `path/to/cts.https.html?worker=0&q=suite:test_path:test_name:foo=1;bar=2;*`. + * If `wptURL` is `undefined`, the query string should be only the query + * `suite:test_path:test_name:foo=1;bar=2;*`. + */ +export function parseExpectationsForTestQuery( + rawExpectations: + | unknown + | { + query: string; + expectation: Expectation; + }[], + query: TestQuery, + wptURL?: URL +) { + if (!Array.isArray(rawExpectations)) { + unreachable('Expectations should be an array'); + } + const expectations: TestQueryWithExpectation[] = []; + for (const entry of rawExpectations) { + assert(typeof entry === 'object'); + const rawExpectation = entry as { query?: string; expectation?: string }; + assert(rawExpectation.query !== undefined, 'Expectation missing query string'); + assert(rawExpectation.expectation !== undefined, 'Expectation missing expectation string'); + + let expectationQuery: TestQuery; + if (wptURL !== undefined) { + const expectationURL = new URL(`${wptURL.origin}/${entry.query}`); + if (expectationURL.pathname !== wptURL.pathname) { + continue; + } + assert( + expectationURL.pathname === wptURL.pathname, + `Invalid expectation path ${expectationURL.pathname} +Expectation should be of the form path/to/cts.https.html?worker=0&q=suite:test_path:test_name:foo=1;bar=2;... + ` + ); + + const params = expectationURL.searchParams; + if (optionEnabled('worker', params) !== optionEnabled('worker', wptURL.searchParams)) { + continue; + } + + const qs = params.getAll('q'); + assert(qs.length === 1, 'currently, there must be exactly one ?q= in the expectation string'); + expectationQuery = parseQuery(qs[0]); + } else { + expectationQuery = parseQuery(entry.query); + } + + // Strip params from multicase expectations so that an expectation of foo=2;* + // is stored if the test query is bar=3;* + const queryForFilter = + expectationQuery instanceof TestQueryMultiCase + ? new TestQueryMultiCase( + expectationQuery.suite, + expectationQuery.filePathParts, + expectationQuery.testPathParts, + {} + ) + : expectationQuery; + + if (compareQueries(query, queryForFilter) === Ordering.Unordered) { + continue; + } + + switch (entry.expectation) { + case 'pass': + case 'skip': + case 'fail': + break; + default: + unreachable(`Invalid expectation ${entry.expectation}`); + } + + expectations.push({ + query: expectationQuery, + expectation: entry.expectation, + }); + } + return expectations; +} + +/** + * For display purposes only, produces a "relative" query string from parent to child. + * Used in the wpt runtime to reduce the verbosity of logs. + */ +export function relativeQueryString(parent: TestQuery, child: TestQuery): string { + const ordering = compareQueries(parent, child); + if (ordering === Ordering.Equal) { + return ''; + } else if (ordering === Ordering.StrictSuperset) { + const parentString = parent.toString(); + assert(parentString.endsWith(kWildcard)); + const childString = child.toString(); + assert( + childString.startsWith(parentString.substring(0, parentString.length - 2)), + 'impossible?: childString does not start with parentString[:-2]' + ); + return childString.substring(parentString.length - 2); + } else { + unreachable( + `relativeQueryString arguments have invalid ordering ${ordering}:\n${parent}\n${child}` + ); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/separators.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/separators.ts new file mode 100644 index 0000000000..0c8f6ea9a9 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/separators.ts @@ -0,0 +1,14 @@ +/** Separator between big parts: suite:file:test:case */ +export const kBigSeparator = ':'; + +/** Separator between path,to,file or path,to,test */ +export const kPathSeparator = ','; + +/** Separator between k=v;k=v */ +export const kParamSeparator = ';'; + +/** Separator between key and value in k=v */ +export const kParamKVSeparator = '='; + +/** Final wildcard, if query is not single-case */ +export const kWildcard = '*'; diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/stringify_params.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/stringify_params.ts new file mode 100644 index 0000000000..907cc0791a --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/stringify_params.ts @@ -0,0 +1,44 @@ +import { TestParams } from '../../framework/fixture.js'; +import { assert } from '../../util/util.js'; +import { JSONWithUndefined, badParamValueChars, paramKeyIsPublic } from '../params_utils.js'; + +import { stringifyParamValue, stringifyParamValueUniquely } from './json_param_value.js'; +import { kParamKVSeparator, kParamSeparator, kWildcard } from './separators.js'; + +export function stringifyPublicParams(p: TestParams, addWildcard = false): string { + const parts = Object.keys(p) + .filter(k => paramKeyIsPublic(k)) + .map(k => stringifySingleParam(k, p[k])); + + if (addWildcard) parts.push(kWildcard); + + return parts.join(kParamSeparator); +} + +/** + * An _approximately_ unique string representing a CaseParams value. + */ +export function stringifyPublicParamsUniquely(p: TestParams): string { + const keys = Object.keys(p).sort(); + return keys + .filter(k => paramKeyIsPublic(k)) + .map(k => stringifySingleParamUniquely(k, p[k])) + .join(kParamSeparator); +} + +export function stringifySingleParam(k: string, v: JSONWithUndefined) { + return `${k}${kParamKVSeparator}${stringifySingleParamValue(v)}`; +} + +function stringifySingleParamUniquely(k: string, v: JSONWithUndefined) { + return `${k}${kParamKVSeparator}${stringifyParamValueUniquely(v)}`; +} + +function stringifySingleParamValue(v: JSONWithUndefined): string { + const s = stringifyParamValue(v); + assert( + !badParamValueChars.test(s), + `JSON.stringified param value must not match ${badParamValueChars} - was ${s}` + ); + return s; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/query/validQueryPart.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/query/validQueryPart.ts new file mode 100644 index 0000000000..62184adb62 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/query/validQueryPart.ts @@ -0,0 +1,2 @@ +/** Applies to group parts, test parts, params keys. */ +export const validQueryPart = /^[a-zA-Z0-9_]+$/; diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/stack.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/stack.ts new file mode 100644 index 0000000000..5de54088c8 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/stack.ts @@ -0,0 +1,82 @@ +// Returns the stack trace of an Error, but without the extra boilerplate at the bottom +// (e.g. RunCaseSpecific, processTicksAndRejections, etc.), for logging. +export function extractImportantStackTrace(e: Error): string { + let stack = e.stack; + if (!stack) { + return ''; + } + const redundantMessage = 'Error: ' + e.message + '\n'; + if (stack.startsWith(redundantMessage)) { + stack = stack.substring(redundantMessage.length); + } + + const lines = stack.split('\n'); + for (let i = lines.length - 1; i >= 0; --i) { + const line = lines[i]; + if (line.indexOf('.spec.') !== -1) { + return lines.slice(0, i + 1).join('\n'); + } + } + return stack; +} + +// *** Examples *** +// +// Node fail() +// > Error: +// > at CaseRecorder.fail (/Users/kainino/src/cts/src/common/framework/logger.ts:99:30) +// > at RunCaseSpecific.exports.g.test.t [as fn] (/Users/kainino/src/cts/src/unittests/logger.spec.ts:80:7) +// x at RunCaseSpecific.run (/Users/kainino/src/cts/src/common/framework/test_group.ts:121:18) +// x at processTicksAndRejections (internal/process/task_queues.js:86:5) +// +// Node throw +// > Error: hello +// > at RunCaseSpecific.g.test.t [as fn] (/Users/kainino/src/cts/src/unittests/test_group.spec.ts:51:11) +// x at RunCaseSpecific.run (/Users/kainino/src/cts/src/common/framework/test_group.ts:121:18) +// x at processTicksAndRejections (internal/process/task_queues.js:86:5) +// +// Firefox fail() +// > fail@http://localhost:8080/out/framework/logger.js:104:30 +// > expect@http://localhost:8080/out/framework/default_fixture.js:59:16 +// > @http://localhost:8080/out/unittests/util.spec.js:35:5 +// x run@http://localhost:8080/out/framework/test_group.js:119:18 +// +// Firefox throw +// > @http://localhost:8080/out/unittests/test_group.spec.js:48:11 +// x run@http://localhost:8080/out/framework/test_group.js:119:18 +// +// Safari fail() +// > fail@http://localhost:8080/out/framework/logger.js:104:39 +// > expect@http://localhost:8080/out/framework/default_fixture.js:59:20 +// > http://localhost:8080/out/unittests/util.spec.js:35:11 +// x http://localhost:8080/out/framework/test_group.js:119:20 +// x asyncFunctionResume@[native code] +// x [native code] +// x promiseReactionJob@[native code] +// +// Safari throw +// > http://localhost:8080/out/unittests/test_group.spec.js:48:20 +// x http://localhost:8080/out/framework/test_group.js:119:20 +// x asyncFunctionResume@[native code] +// x [native code] +// x promiseReactionJob@[native code] +// +// Chrome fail() +// x Error +// x at CaseRecorder.fail (http://localhost:8080/out/framework/logger.js:104:30) +// x at DefaultFixture.expect (http://localhost:8080/out/framework/default_fixture.js:59:16) +// > at RunCaseSpecific.fn (http://localhost:8080/out/unittests/util.spec.js:35:5) +// x at RunCaseSpecific.run (http://localhost:8080/out/framework/test_group.js:119:18) +// x at async runCase (http://localhost:8080/out/runtime/standalone.js:37:17) +// x at async http://localhost:8080/out/runtime/standalone.js:102:7 +// +// Chrome throw +// x Error: hello +// > at RunCaseSpecific.fn (http://localhost:8080/out/unittests/test_group.spec.js:48:11) +// x at RunCaseSpecific.run (http://localhost:8080/out/framework/test_group.js:119:18)" +// x at async Promise.all (index 0) +// x at async TestGroupTest.run (http://localhost:8080/out/unittests/test_group_test.js:6:5) +// x at async RunCaseSpecific.fn (http://localhost:8080/out/unittests/test_group.spec.js:53:15) +// x at async RunCaseSpecific.run (http://localhost:8080/out/framework/test_group.js:119:7) +// x at async runCase (http://localhost:8080/out/runtime/standalone.js:37:17) +// x at async http://localhost:8080/out/runtime/standalone.js:102:7 diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/test_group.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/test_group.ts new file mode 100644 index 0000000000..63f017083c --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/test_group.ts @@ -0,0 +1,646 @@ +import { + Fixture, + SubcaseBatchState, + SkipTestCase, + TestParams, + UnexpectedPassError, +} from '../framework/fixture.js'; +import { + CaseParamsBuilder, + builderIterateCasesWithSubcases, + kUnitCaseParamsBuilder, + ParamsBuilderBase, + SubcaseParamsBuilder, +} from '../framework/params_builder.js'; +import { globalTestConfig } from '../framework/test_config.js'; +import { Expectation } from '../internal/logging/result.js'; +import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; +import { extractPublicParams, Merged, mergeParams } from '../internal/params_utils.js'; +import { compareQueries, Ordering } from '../internal/query/compare.js'; +import { TestQuerySingleCase, TestQueryWithExpectation } from '../internal/query/query.js'; +import { kPathSeparator } from '../internal/query/separators.js'; +import { + stringifyPublicParams, + stringifyPublicParamsUniquely, +} from '../internal/query/stringify_params.js'; +import { validQueryPart } from '../internal/query/validQueryPart.js'; +import { assert, unreachable } from '../util/util.js'; + +export type RunFn = ( + rec: TestCaseRecorder, + expectations?: TestQueryWithExpectation[] +) => Promise<void>; + +export interface TestCaseID { + readonly test: readonly string[]; + readonly params: TestParams; +} + +export interface RunCase { + readonly id: TestCaseID; + readonly isUnimplemented: boolean; + run( + rec: TestCaseRecorder, + selfQuery: TestQuerySingleCase, + expectations: TestQueryWithExpectation[] + ): Promise<void>; +} + +// Interface for defining tests +export interface TestGroupBuilder<S extends SubcaseBatchState, F extends Fixture<S>> { + test(name: string): TestBuilderWithName<S, F>; +} +export function makeTestGroup<S extends SubcaseBatchState, F extends Fixture<S>>( + fixture: FixtureClass<S, F> +): TestGroupBuilder<S, F> { + return new TestGroup((fixture as unknown) as FixtureClass); +} + +// Interfaces for running tests +export interface IterableTestGroup { + iterate(): Iterable<IterableTest>; + validate(): void; +} +export interface IterableTest { + testPath: string[]; + description: string | undefined; + readonly testCreationStack: Error; + iterate(): Iterable<RunCase>; +} + +export function makeTestGroupForUnitTesting<F extends Fixture>( + fixture: FixtureClass<SubcaseBatchState, F> +): TestGroup<SubcaseBatchState, F> { + return new TestGroup(fixture); +} + +export type FixtureClass< + S extends SubcaseBatchState = SubcaseBatchState, + F extends Fixture<S> = Fixture<S> +> = { + new (sharedState: S, log: TestCaseRecorder, params: TestParams): F; + MakeSharedState(params: TestParams): S; +}; +type TestFn<F extends Fixture, P extends {}> = (t: F & { params: P }) => Promise<void> | void; +type BeforeAllSubcasesFn<S extends SubcaseBatchState, P extends {}> = ( + s: S & { params: P } +) => Promise<void> | void; + +export class TestGroup<S extends SubcaseBatchState, F extends Fixture<S>> + implements TestGroupBuilder<S, F> { + private fixture: FixtureClass; + private seen: Set<string> = new Set(); + private tests: Array<TestBuilder<S, F>> = []; + + constructor(fixture: FixtureClass) { + this.fixture = fixture; + } + + iterate(): Iterable<IterableTest> { + return this.tests; + } + + private checkName(name: string): void { + assert( + // Shouldn't happen due to the rule above. Just makes sure that treating + // unencoded strings as encoded strings is OK. + name === decodeURIComponent(name), + `Not decodeURIComponent-idempotent: ${name} !== ${decodeURIComponent(name)}` + ); + assert(!this.seen.has(name), `Duplicate test name: ${name}`); + + this.seen.add(name); + } + + test(name: string): TestBuilderWithName<S, F> { + const testCreationStack = new Error(`Test created: ${name}`); + + this.checkName(name); + + const parts = name.split(kPathSeparator); + for (const p of parts) { + assert(validQueryPart.test(p), `Invalid test name part ${p}; must match ${validQueryPart}`); + } + + const test = new TestBuilder(parts, this.fixture, testCreationStack); + this.tests.push(test); + return (test as unknown) as TestBuilderWithName<S, F>; + } + + validate(): void { + for (const test of this.tests) { + test.validate(); + } + } +} + +interface TestBuilderWithName<S extends SubcaseBatchState, F extends Fixture<S>> + extends TestBuilderWithParams<S, F, {}, {}> { + desc(description: string): this; + /** + * A noop function to associate a test with the relevant part of the specification. + * + * @param url a link to the spec where test is extracted from. + */ + specURL(url: string): this; + /** + * Parameterize the test, generating multiple cases, each possibly having subcases. + * + * The `unit` value passed to the `cases` callback is an immutable constant + * `CaseParamsBuilder<{}>` representing the "unit" builder `[ {} ]`, + * provided for convenience. The non-callback overload can be used if `unit` is not needed. + */ + params<CaseP extends {}, SubcaseP extends {}>( + cases: (unit: CaseParamsBuilder<{}>) => ParamsBuilderBase<CaseP, SubcaseP> + ): TestBuilderWithParams<S, F, CaseP, SubcaseP>; + /** + * Parameterize the test, generating multiple cases, each possibly having subcases. + * + * Use the callback overload of this method if a "unit" builder is needed. + */ + params<CaseP extends {}, SubcaseP extends {}>( + cases: ParamsBuilderBase<CaseP, SubcaseP> + ): TestBuilderWithParams<S, F, CaseP, SubcaseP>; + + /** + * Parameterize the test, generating multiple cases, without subcases. + */ + paramsSimple<P extends {}>(cases: Iterable<P>): TestBuilderWithParams<S, F, P, {}>; + + /** + * Parameterize the test, generating one case with multiple subcases. + */ + paramsSubcasesOnly<P extends {}>(subcases: Iterable<P>): TestBuilderWithParams<S, F, {}, P>; + /** + * Parameterize the test, generating one case with multiple subcases. + * + * The `unit` value passed to the `subcases` callback is an immutable constant + * `SubcaseParamsBuilder<{}>`, with one empty case `{}` and one empty subcase `{}`. + */ + paramsSubcasesOnly<P extends {}>( + subcases: (unit: SubcaseParamsBuilder<{}, {}>) => SubcaseParamsBuilder<{}, P> + ): TestBuilderWithParams<S, F, {}, P>; +} + +interface TestBuilderWithParams< + S extends SubcaseBatchState, + F extends Fixture<S>, + CaseP extends {}, + SubcaseP extends {} +> { + /** + * Limit subcases to a maximum number of per testcase. + * @param b the maximum number of subcases per testcase. + * + * If the number of subcases exceeds `b`, add an internal + * numeric, incrementing `batch__` param to split subcases + * into groups of at most `b` subcases. + */ + batch(b: number): this; + /** + * Run a function on shared subcase batch state before each + * batch of subcases. + * @param fn the function to run. It is called with the test + * fixture's shared subcase batch state. + * + * Generally, this function should be careful to avoid mutating + * any state on the shared subcase batch state which could result + * in unexpected order-dependent test behavior. + */ + beforeAllSubcases(fn: BeforeAllSubcasesFn<S, CaseP>): this; + /** + * Set the test function. + * @param fn the test function. + */ + fn(fn: TestFn<F, Merged<CaseP, SubcaseP>>): void; + /** + * Mark the test as unimplemented. + */ + unimplemented(): void; +} + +class TestBuilder<S extends SubcaseBatchState, F extends Fixture> { + readonly testPath: string[]; + isUnimplemented: boolean; + description: string | undefined; + readonly testCreationStack: Error; + + private readonly fixture: FixtureClass; + private testFn: TestFn<Fixture, {}> | undefined; + private beforeFn: BeforeAllSubcasesFn<SubcaseBatchState, {}> | undefined; + private testCases?: ParamsBuilderBase<{}, {}> = undefined; + private batchSize: number = 0; + + constructor(testPath: string[], fixture: FixtureClass, testCreationStack: Error) { + this.testPath = testPath; + this.isUnimplemented = false; + this.fixture = fixture; + this.testCreationStack = testCreationStack; + } + + desc(description: string): this { + this.description = description.trim(); + return this; + } + + specURL(url: string): this { + return this; + } + + beforeAllSubcases(fn: BeforeAllSubcasesFn<SubcaseBatchState, {}>): this { + assert(this.beforeFn === undefined); + this.beforeFn = fn; + return this; + } + + fn(fn: TestFn<Fixture, {}>): void { + // eslint-disable-next-line no-warning-comments + // MAINTENANCE_TODO: add "TODO" if there's no description? (and make sure it only ends up on + // actual tests, not on test parents in the tree, which is what happens if you do it here, not + // sure why) + assert(this.testFn === undefined); + this.testFn = fn; + } + + batch(b: number): this { + this.batchSize = b; + return this; + } + + unimplemented(): void { + assert(this.testFn === undefined); + + this.description = + (this.description ? this.description + '\n\n' : '') + 'TODO: .unimplemented()'; + this.isUnimplemented = true; + + this.testFn = () => { + throw new SkipTestCase('test unimplemented'); + }; + } + + validate(): void { + const testPathString = this.testPath.join(kPathSeparator); + assert(this.testFn !== undefined, () => { + let s = `Test is missing .fn(): ${testPathString}`; + if (this.testCreationStack.stack) { + s += `\n-> test created at:\n${this.testCreationStack.stack}`; + } + return s; + }); + + if (this.testCases === undefined) { + return; + } + + const seen = new Set<string>(); + for (const [caseParams, subcases] of builderIterateCasesWithSubcases(this.testCases)) { + for (const subcaseParams of subcases ?? [{}]) { + const params = mergeParams(caseParams, subcaseParams); + assert(this.batchSize === 0 || !('batch__' in params)); + + // stringifyPublicParams also checks for invalid params values + const testcaseString = stringifyPublicParams(params); + + // A (hopefully) unique representation of a params value. + const testcaseStringUnique = stringifyPublicParamsUniquely(params); + assert( + !seen.has(testcaseStringUnique), + `Duplicate public test case params for test ${testPathString}: ${testcaseString}` + ); + seen.add(testcaseStringUnique); + } + } + } + + params( + cases: ((unit: CaseParamsBuilder<{}>) => ParamsBuilderBase<{}, {}>) | ParamsBuilderBase<{}, {}> + ): TestBuilder<S, F> { + assert(this.testCases === undefined, 'test case is already parameterized'); + if (cases instanceof Function) { + this.testCases = cases(kUnitCaseParamsBuilder); + } else { + this.testCases = cases; + } + return this; + } + + paramsSimple(cases: Iterable<{}>): TestBuilder<S, F> { + assert(this.testCases === undefined, 'test case is already parameterized'); + this.testCases = kUnitCaseParamsBuilder.combineWithParams(cases); + return this; + } + + paramsSubcasesOnly( + subcases: Iterable<{}> | ((unit: SubcaseParamsBuilder<{}, {}>) => SubcaseParamsBuilder<{}, {}>) + ): TestBuilder<S, F> { + if (subcases instanceof Function) { + return this.params(subcases(kUnitCaseParamsBuilder.beginSubcases())); + } else { + return this.params(kUnitCaseParamsBuilder.beginSubcases().combineWithParams(subcases)); + } + } + + *iterate(): IterableIterator<RunCase> { + assert(this.testFn !== undefined, 'No test function (.fn()) for test'); + this.testCases ??= kUnitCaseParamsBuilder; + for (const [caseParams, subcases] of builderIterateCasesWithSubcases(this.testCases)) { + if (this.batchSize === 0 || subcases === undefined) { + yield new RunCaseSpecific( + this.testPath, + caseParams, + this.isUnimplemented, + subcases, + this.fixture, + this.testFn, + this.beforeFn, + this.testCreationStack + ); + } else { + const subcaseArray = Array.from(subcases); + if (subcaseArray.length <= this.batchSize) { + yield new RunCaseSpecific( + this.testPath, + caseParams, + this.isUnimplemented, + subcaseArray, + this.fixture, + this.testFn, + this.beforeFn, + this.testCreationStack + ); + } else { + for (let i = 0; i < subcaseArray.length; i = i + this.batchSize) { + yield new RunCaseSpecific( + this.testPath, + { ...caseParams, batch__: i / this.batchSize }, + this.isUnimplemented, + subcaseArray.slice(i, Math.min(subcaseArray.length, i + this.batchSize)), + this.fixture, + this.testFn, + this.beforeFn, + this.testCreationStack + ); + } + } + } + } + } +} + +class RunCaseSpecific implements RunCase { + readonly id: TestCaseID; + readonly isUnimplemented: boolean; + + private readonly params: {}; + private readonly subcases: Iterable<{}> | undefined; + private readonly fixture: FixtureClass; + private readonly fn: TestFn<Fixture, {}>; + private readonly beforeFn?: BeforeAllSubcasesFn<SubcaseBatchState, {}>; + private readonly testCreationStack: Error; + + constructor( + testPath: string[], + params: {}, + isUnimplemented: boolean, + subcases: Iterable<{}> | undefined, + fixture: FixtureClass, + fn: TestFn<Fixture, {}>, + beforeFn: BeforeAllSubcasesFn<SubcaseBatchState, {}> | undefined, + testCreationStack: Error + ) { + this.id = { test: testPath, params: extractPublicParams(params) }; + this.isUnimplemented = isUnimplemented; + this.params = params; + this.subcases = subcases; + this.fixture = fixture; + this.fn = fn; + this.beforeFn = beforeFn; + this.testCreationStack = testCreationStack; + } + + async runTest( + rec: TestCaseRecorder, + sharedState: SubcaseBatchState, + params: TestParams, + throwSkip: boolean, + expectedStatus: Expectation + ): Promise<void> { + try { + rec.beginSubCase(); + if (expectedStatus === 'skip') { + throw new SkipTestCase('Skipped by expectations'); + } + + const inst = new this.fixture(sharedState, rec, params); + try { + await inst.init(); + await this.fn(inst as Fixture & { params: {} }); + } finally { + // Runs as long as constructor succeeded, even if initialization or the test failed. + await inst.finalize(); + } + } catch (ex) { + // There was an exception from constructor, init, test, or finalize. + // An error from init or test may have been a SkipTestCase. + // An error from finalize may have been an eventualAsyncExpectation failure + // or unexpected validation/OOM error from the GPUDevice. + if (throwSkip && ex instanceof SkipTestCase) { + throw ex; + } + rec.threw(ex); + } finally { + try { + rec.endSubCase(expectedStatus); + } catch (ex) { + assert(ex instanceof UnexpectedPassError); + ex.message = `Testcase passed unexpectedly.`; + ex.stack = this.testCreationStack.stack; + rec.warn(ex); + } + } + } + + async run( + rec: TestCaseRecorder, + selfQuery: TestQuerySingleCase, + expectations: TestQueryWithExpectation[] + ): Promise<void> { + const getExpectedStatus = (selfQueryWithSubParams: TestQuerySingleCase) => { + let didSeeFail = false; + for (const exp of expectations) { + const ordering = compareQueries(exp.query, selfQueryWithSubParams); + if (ordering === Ordering.Unordered || ordering === Ordering.StrictSubset) { + continue; + } + + switch (exp.expectation) { + // Skip takes precedence. If there is any expectation indicating a skip, + // signal it immediately. + case 'skip': + return 'skip'; + case 'fail': + // Otherwise, indicate that we might expect a failure. + didSeeFail = true; + break; + default: + unreachable(); + } + } + return didSeeFail ? 'fail' : 'pass'; + }; + + const { testHeartbeatCallback, maxSubcasesInFlight } = globalTestConfig; + try { + rec.start(); + const sharedState = this.fixture.MakeSharedState(this.params); + try { + await sharedState.init(); + if (this.beforeFn) { + await this.beforeFn(sharedState); + } + await sharedState.postInit(); + testHeartbeatCallback(); + + let allPreviousSubcasesFinalizedPromise: Promise<void> = Promise.resolve(); + if (this.subcases) { + let totalCount = 0; + let skipCount = 0; + + // If there are too many subcases in flight, starting the next subcase will register + // `resolvePromiseBlockingSubcase` and wait until `subcaseFinishedCallback` is called. + let subcasesInFlight = 0; + let resolvePromiseBlockingSubcase: (() => void) | undefined = undefined; + const subcaseFinishedCallback = () => { + subcasesInFlight -= 1; + // If there is any subcase waiting on a previous subcase to finish, + // unblock it now, and clear the resolve callback. + if (resolvePromiseBlockingSubcase) { + resolvePromiseBlockingSubcase(); + resolvePromiseBlockingSubcase = undefined; + } + }; + + for (const subParams of this.subcases) { + // Make a recorder that will defer all calls until `allPreviousSubcasesFinalizedPromise` + // resolves. Waiting on `allPreviousSubcasesFinalizedPromise` ensures that + // logs from all the previous subcases have been flushed before flushing new logs. + const subcasePrefix = 'subcase: ' + stringifyPublicParams(subParams); + const subRec = new Proxy(rec, { + get: (target, k: keyof TestCaseRecorder) => { + const prop = TestCaseRecorder.prototype[k]; + if (typeof prop === 'function') { + testHeartbeatCallback(); + return function (...args: Parameters<typeof prop>) { + void allPreviousSubcasesFinalizedPromise.then(() => { + // Prepend the subcase name to all error messages. + for (const arg of args) { + if (arg instanceof Error) { + try { + arg.message = subcasePrefix + '\n' + arg.message; + } catch { + // If that fails (e.g. on DOMException), try to put it in the stack: + let stack = subcasePrefix; + if (arg.stack) stack += '\n' + arg.stack; + try { + arg.stack = stack; + } catch { + // If that fails too, just silence it. + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rv = (prop as any).apply(target, args); + // Because this proxy executes functions in a deferred manner, + // it should never be used for functions that need to return a value. + assert(rv === undefined); + }); + }; + } + return prop; + }, + }); + + const params = mergeParams(this.params, subParams); + const subcaseQuery = new TestQuerySingleCase( + selfQuery.suite, + selfQuery.filePathParts, + selfQuery.testPathParts, + params + ); + + // Limit the maximum number of subcases in flight. + if (subcasesInFlight >= maxSubcasesInFlight) { + await new Promise<void>(resolve => { + // There should only be one subcase waiting at a time. + assert(resolvePromiseBlockingSubcase === undefined); + resolvePromiseBlockingSubcase = resolve; + }); + } + + subcasesInFlight += 1; + // Runs async without waiting so that subsequent subcases can start. + // All finalization steps will be waited on at the end of the testcase. + const finalizePromise = this.runTest( + subRec, + sharedState, + params, + /* throwSkip */ true, + getExpectedStatus(subcaseQuery) + ) + .then(() => { + subRec.info(new Error('OK')); + }) + .catch(ex => { + if (ex instanceof SkipTestCase) { + // Convert SkipTestCase to info messages + ex.message = 'subcase skipped: ' + ex.message; + subRec.info(ex); + ++skipCount; + } else { + // Since we are catching all error inside runTest(), this should never happen + subRec.threw(ex); + } + }) + .finally(subcaseFinishedCallback); + + allPreviousSubcasesFinalizedPromise = allPreviousSubcasesFinalizedPromise.then( + () => finalizePromise + ); + ++totalCount; + } + + // Wait for all subcases to finalize and report their results. + await allPreviousSubcasesFinalizedPromise; + + if (skipCount === totalCount) { + rec.skipped(new SkipTestCase('all subcases were skipped')); + } + } else { + await this.runTest( + rec, + sharedState, + this.params, + /* throwSkip */ false, + getExpectedStatus(selfQuery) + ); + } + } finally { + testHeartbeatCallback(); + // Runs as long as the shared state constructor succeeded, even if initialization or a test failed. + await sharedState.finalize(); + testHeartbeatCallback(); + } + } catch (ex) { + // There was an exception from sharedState/fixture constructor, init, beforeFn, or test. + // An error from beforeFn may have been SkipTestCase. + // An error from finalize may have been an eventualAsyncExpectation failure + // or unexpected validation/OOM error from the GPUDevice. + rec.threw(ex); + } finally { + rec.finish(); + } + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/test_suite_listing.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/test_suite_listing.ts new file mode 100644 index 0000000000..2d2b555366 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/test_suite_listing.ts @@ -0,0 +1,15 @@ +// A listing of all specs within a single suite. This is the (awaited) type of +// `groups` in '{cts,unittests}/listing.ts' and `listing` in the auto-generated +// 'out/{cts,unittests}/listing.js' files (see tools/gen_listings). +export type TestSuiteListing = TestSuiteListingEntry[]; + +export type TestSuiteListingEntry = TestSuiteListingEntrySpec | TestSuiteListingEntryReadme; + +interface TestSuiteListingEntrySpec { + readonly file: string[]; +} + +interface TestSuiteListingEntryReadme { + readonly file: string[]; + readonly readme: string; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/tree.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/tree.ts new file mode 100644 index 0000000000..204a4f693a --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/tree.ts @@ -0,0 +1,575 @@ +import { RunCase, RunFn } from '../internal/test_group.js'; +import { assert } from '../util/util.js'; + +import { TestFileLoader } from './file_loader.js'; +import { TestParamsRW } from './params_utils.js'; +import { compareQueries, Ordering } from './query/compare.js'; +import { + TestQuery, + TestQueryMultiCase, + TestQuerySingleCase, + TestQueryMultiFile, + TestQueryMultiTest, +} from './query/query.js'; +import { kBigSeparator, kWildcard, kPathSeparator, kParamSeparator } from './query/separators.js'; +import { stringifySingleParam } from './query/stringify_params.js'; +import { StacklessError } from './util.js'; + +// `loadTreeForQuery()` loads a TestTree for a given queryToLoad. +// The resulting tree is a linked-list all the way from `suite:*` to queryToLoad, +// and under queryToLoad is a tree containing every case matched by queryToLoad. +// +// `subqueriesToExpand` influences the `collapsible` flag on nodes in the resulting tree. +// A node is considered "collapsible" if none of the subqueriesToExpand is a StrictSubset +// of that node. +// +// In WebKit/Blink-style web_tests, an expectation file marks individual cts.https.html "variants +// as "Failure", "Crash", etc. By passing in the list of expectations as the subqueriesToExpand, +// we can programmatically subdivide the cts.https.html "variants" list to be able to implement +// arbitrarily-fine suppressions (instead of having to suppress entire test files, which would +// lose a lot of coverage). +// +// `iterateCollapsedNodes()` produces the list of queries for the variants list. +// +// Though somewhat complicated, this system has important benefits: +// - Avoids having to suppress entire test files, which would cause large test coverage loss. +// - Minimizes the number of page loads needed for fine-grained suppressions. +// (In the naive case, we could do one page load per test case - but the test suite would +// take impossibly long to run.) +// - Enables developers to put any number of tests in one file as appropriate, without worrying +// about expectation granularity. + +interface TestTreeNodeBase<T extends TestQuery> { + readonly query: T; + /** + * Readable "relative" name for display in standalone runner. + * Not always the exact relative name, because sometimes there isn't + * one (e.g. s:f:* relative to s:f,*), but something that is readable. + */ + readonly readableRelativeName: string; + subtreeCounts?: { tests: number; nodesWithTODO: number }; +} + +export interface TestSubtree<T extends TestQuery = TestQuery> extends TestTreeNodeBase<T> { + readonly children: Map<string, TestTreeNode>; + readonly collapsible: boolean; + description?: string; + readonly testCreationStack?: Error; +} + +export interface TestTreeLeaf extends TestTreeNodeBase<TestQuerySingleCase> { + readonly run: RunFn; + readonly isUnimplemented?: boolean; + subtreeCounts?: undefined; +} + +export type TestTreeNode = TestSubtree | TestTreeLeaf; + +/** + * When iterating through "collapsed" tree nodes, indicates how many "query levels" to traverse + * through before starting to collapse nodes. + * + * Corresponds with TestQueryLevel, but excludes 4 (SingleCase): + * - 1 = MultiFile. Expands so every file is in the collapsed tree. + * - 2 = MultiTest. Expands so every test is in the collapsed tree. + * - 3 = MultiCase. Expands so every case is in the collapsed tree (i.e. collapsing disabled). + */ +export type ExpandThroughLevel = 1 | 2 | 3; + +export class TestTree { + /** + * The `queryToLoad` that this test tree was created for. + * Test trees are always rooted at `suite:*`, but they only contain nodes that fit + * within `forQuery`. + * + * This is used for `iterateCollapsedNodes` which only starts collapsing at the next + * `TestQueryLevel` after `forQuery`. + */ + readonly forQuery: TestQuery; + readonly root: TestSubtree; + + constructor(forQuery: TestQuery, root: TestSubtree) { + this.forQuery = forQuery; + TestTree.propagateCounts(root); + this.root = root; + assert( + root.query.level === 1 && root.query.depthInLevel === 0, + 'TestTree root must be the root (suite:*)' + ); + } + + /** + * Iterate through the leaves of a version of the tree which has been pruned to exclude + * subtrees which: + * - are at a deeper `TestQueryLevel` than `this.forQuery`, and + * - were not a `Ordering.StrictSubset` of any of the `subqueriesToExpand` during tree creation. + */ + iterateCollapsedNodes({ + includeIntermediateNodes = false, + includeEmptySubtrees = false, + alwaysExpandThroughLevel, + }: { + /** Whether to include intermediate tree nodes or only collapsed-leaves. */ + includeIntermediateNodes?: boolean; + /** Whether to include collapsed-leaves with no children. */ + includeEmptySubtrees?: boolean; + /** Never collapse nodes up through this level. */ + alwaysExpandThroughLevel: ExpandThroughLevel; + }): IterableIterator<Readonly<TestTreeNode>> { + const expandThroughLevel = Math.max(this.forQuery.level, alwaysExpandThroughLevel); + return TestTree.iterateSubtreeNodes(this.root, { + includeIntermediateNodes, + includeEmptySubtrees, + expandThroughLevel, + }); + } + + iterateLeaves(): IterableIterator<Readonly<TestTreeLeaf>> { + return TestTree.iterateSubtreeLeaves(this.root); + } + + /** + * Dissolve nodes which have only one child, e.g.: + * a,* { a,b,* { a,b:* { ... } } } + * collapses down into: + * a,* { a,b:* { ... } } + * which is less needlessly verbose when displaying the tree in the standalone runner. + */ + dissolveSingleChildTrees(): void { + const newRoot = dissolveSingleChildTrees(this.root); + assert(newRoot === this.root); + } + + toString(): string { + return TestTree.subtreeToString('(root)', this.root, ''); + } + + static *iterateSubtreeNodes( + subtree: TestSubtree, + opts: { + includeIntermediateNodes: boolean; + includeEmptySubtrees: boolean; + expandThroughLevel: number; + } + ): IterableIterator<TestTreeNode> { + if (opts.includeIntermediateNodes) { + yield subtree; + } + + for (const [, child] of subtree.children) { + if ('children' in child) { + // Is a subtree + const collapsible = child.collapsible && child.query.level > opts.expandThroughLevel; + if (child.children.size > 0 && !collapsible) { + yield* TestTree.iterateSubtreeNodes(child, opts); + } else if (child.children.size > 0 || opts.includeEmptySubtrees) { + // Don't yield empty subtrees (e.g. files with no tests) unless includeEmptySubtrees + yield child; + } + } else { + // Is a leaf + yield child; + } + } + } + + static *iterateSubtreeLeaves(subtree: TestSubtree): IterableIterator<TestTreeLeaf> { + for (const [, child] of subtree.children) { + if ('children' in child) { + yield* TestTree.iterateSubtreeLeaves(child); + } else { + yield child; + } + } + } + + /** Propagate the subtreeTODOs/subtreeTests state upward from leaves to parent nodes. */ + static propagateCounts(subtree: TestSubtree): { tests: number; nodesWithTODO: number } { + subtree.subtreeCounts ??= { tests: 0, nodesWithTODO: 0 }; + for (const [, child] of subtree.children) { + if ('children' in child) { + const counts = TestTree.propagateCounts(child); + subtree.subtreeCounts.tests += counts.tests; + subtree.subtreeCounts.nodesWithTODO += counts.nodesWithTODO; + } + } + return subtree.subtreeCounts; + } + + /** Displays counts in the format `(Nodes with TODOs) / (Total test count)`. */ + static countsToString(tree: TestTreeNode): string { + if (tree.subtreeCounts) { + return `${tree.subtreeCounts.nodesWithTODO} / ${tree.subtreeCounts.tests}`; + } else { + return ''; + } + } + + static subtreeToString(name: string, tree: TestTreeNode, indent: string): string { + const collapsible = 'run' in tree ? '>' : tree.collapsible ? '+' : '-'; + let s = + indent + + `${collapsible} ${TestTree.countsToString(tree)} ${JSON.stringify(name)} => ${tree.query}`; + if ('children' in tree) { + if (tree.description !== undefined) { + s += `\n${indent} | ${JSON.stringify(tree.description)}`; + } + + for (const [name, child] of tree.children) { + s += '\n' + TestTree.subtreeToString(name, child, indent + ' '); + } + } + return s; + } +} + +// MAINTENANCE_TODO: Consider having subqueriesToExpand actually impact the depth-order of params +// in the tree. +export async function loadTreeForQuery( + loader: TestFileLoader, + queryToLoad: TestQuery, + subqueriesToExpand: TestQuery[] +): Promise<TestTree> { + const suite = queryToLoad.suite; + const specs = await loader.listing(suite); + + const subqueriesToExpandEntries = Array.from(subqueriesToExpand.entries()); + const seenSubqueriesToExpand: boolean[] = new Array(subqueriesToExpand.length); + seenSubqueriesToExpand.fill(false); + + const isCollapsible = (subquery: TestQuery) => + subqueriesToExpandEntries.every(([i, toExpand]) => { + const ordering = compareQueries(toExpand, subquery); + + // If toExpand == subquery, no expansion is needed (but it's still "seen"). + if (ordering === Ordering.Equal) seenSubqueriesToExpand[i] = true; + return ordering !== Ordering.StrictSubset; + }); + + // L0 = suite-level, e.g. suite:* + // L1 = file-level, e.g. suite:a,b:* + // L2 = test-level, e.g. suite:a,b:c,d:* + // L3 = case-level, e.g. suite:a,b:c,d: + let foundCase = false; + // L0 is suite:* + const subtreeL0 = makeTreeForSuite(suite, isCollapsible); + for (const entry of specs) { + if (entry.file.length === 0 && 'readme' in entry) { + // Suite-level readme. + setSubtreeDescriptionAndCountTODOs(subtreeL0, entry.readme); + continue; + } + + { + const queryL1 = new TestQueryMultiFile(suite, entry.file); + const orderingL1 = compareQueries(queryL1, queryToLoad); + if (orderingL1 === Ordering.Unordered) { + // File path is not matched by this query. + continue; + } + } + + if ('readme' in entry) { + // Entry is a README that is an ancestor or descendant of the query. + // (It's included for display in the standalone runner.) + + // readmeSubtree is suite:a,b,* + // (This is always going to dedup with a file path, if there are any test spec files under + // the directory that has the README). + const readmeSubtree: TestSubtree<TestQueryMultiFile> = addSubtreeForDirPath( + subtreeL0, + entry.file, + isCollapsible + ); + setSubtreeDescriptionAndCountTODOs(readmeSubtree, entry.readme); + continue; + } + // Entry is a spec file. + + const spec = await loader.importSpecFile(queryToLoad.suite, entry.file); + // subtreeL1 is suite:a,b:* + const subtreeL1: TestSubtree<TestQueryMultiTest> = addSubtreeForFilePath( + subtreeL0, + entry.file, + isCollapsible + ); + setSubtreeDescriptionAndCountTODOs(subtreeL1, spec.description); + + let groupHasTests = false; + for (const t of spec.g.iterate()) { + groupHasTests = true; + { + const queryL2 = new TestQueryMultiCase(suite, entry.file, t.testPath, {}); + const orderingL2 = compareQueries(queryL2, queryToLoad); + if (orderingL2 === Ordering.Unordered) { + // Test path is not matched by this query. + continue; + } + } + + // subtreeL2 is suite:a,b:c,d:* + const subtreeL2: TestSubtree<TestQueryMultiCase> = addSubtreeForTestPath( + subtreeL1, + t.testPath, + t.testCreationStack, + isCollapsible + ); + // This is 1 test. Set tests=1 then count TODOs. + subtreeL2.subtreeCounts ??= { tests: 1, nodesWithTODO: 0 }; + if (t.description) setSubtreeDescriptionAndCountTODOs(subtreeL2, t.description); + + // MAINTENANCE_TODO: If tree generation gets too slow, avoid actually iterating the cases in a + // file if there's no need to (based on the subqueriesToExpand). + for (const c of t.iterate()) { + { + const queryL3 = new TestQuerySingleCase(suite, entry.file, c.id.test, c.id.params); + const orderingL3 = compareQueries(queryL3, queryToLoad); + if (orderingL3 === Ordering.Unordered || orderingL3 === Ordering.StrictSuperset) { + // Case is not matched by this query. + continue; + } + } + + // Leaf for case is suite:a,b:c,d:x=1;y=2 + addLeafForCase(subtreeL2, c, isCollapsible); + + foundCase = true; + } + } + if (!groupHasTests && !subtreeL1.subtreeCounts) { + throw new StacklessError( + `${subtreeL1.query} has no tests - it must have "TODO" in its description` + ); + } + } + + for (const [i, sq] of subqueriesToExpandEntries) { + const subquerySeen = seenSubqueriesToExpand[i]; + if (!subquerySeen) { + throw new StacklessError( + `subqueriesToExpand entry did not match anything \ +(could be wrong, or could be redundant with a previous subquery):\n ${sq.toString()}` + ); + } + } + assert(foundCase, `Query \`${queryToLoad.toString()}\` does not match any cases`); + + return new TestTree(queryToLoad, subtreeL0); +} + +function setSubtreeDescriptionAndCountTODOs( + subtree: TestSubtree<TestQueryMultiFile>, + description: string +) { + assert(subtree.description === undefined); + subtree.description = description.trim(); + subtree.subtreeCounts ??= { tests: 0, nodesWithTODO: 0 }; + if (subtree.description.indexOf('TODO') !== -1) { + subtree.subtreeCounts.nodesWithTODO++; + } +} + +function makeTreeForSuite( + suite: string, + isCollapsible: (sq: TestQuery) => boolean +): TestSubtree<TestQueryMultiFile> { + const query = new TestQueryMultiFile(suite, []); + return { + readableRelativeName: suite + kBigSeparator, + query, + children: new Map(), + collapsible: isCollapsible(query), + }; +} + +function addSubtreeForDirPath( + tree: TestSubtree<TestQueryMultiFile>, + file: string[], + isCollapsible: (sq: TestQuery) => boolean +): TestSubtree<TestQueryMultiFile> { + const subqueryFile: string[] = []; + // To start, tree is suite:* + // This loop goes from that -> suite:a,* -> suite:a,b,* + for (const part of file) { + subqueryFile.push(part); + tree = getOrInsertSubtree(part, tree, () => { + const query = new TestQueryMultiFile(tree.query.suite, subqueryFile); + return { + readableRelativeName: part + kPathSeparator + kWildcard, + query, + collapsible: isCollapsible(query), + }; + }); + } + return tree; +} + +function addSubtreeForFilePath( + tree: TestSubtree<TestQueryMultiFile>, + file: string[], + isCollapsible: (sq: TestQuery) => boolean +): TestSubtree<TestQueryMultiTest> { + // To start, tree is suite:* + // This goes from that -> suite:a,* -> suite:a,b,* + tree = addSubtreeForDirPath(tree, file, isCollapsible); + // This goes from that -> suite:a,b:* + const subtree = getOrInsertSubtree('', tree, () => { + const query = new TestQueryMultiTest(tree.query.suite, tree.query.filePathParts, []); + assert(file.length > 0, 'file path is empty'); + return { + readableRelativeName: file[file.length - 1] + kBigSeparator + kWildcard, + query, + collapsible: isCollapsible(query), + }; + }); + return subtree; +} + +function addSubtreeForTestPath( + tree: TestSubtree<TestQueryMultiTest>, + test: readonly string[], + testCreationStack: Error, + isCollapsible: (sq: TestQuery) => boolean +): TestSubtree<TestQueryMultiCase> { + const subqueryTest: string[] = []; + // To start, tree is suite:a,b:* + // This loop goes from that -> suite:a,b:c,* -> suite:a,b:c,d,* + for (const part of test) { + subqueryTest.push(part); + tree = getOrInsertSubtree(part, tree, () => { + const query = new TestQueryMultiTest( + tree.query.suite, + tree.query.filePathParts, + subqueryTest + ); + return { + readableRelativeName: part + kPathSeparator + kWildcard, + query, + collapsible: isCollapsible(query), + }; + }); + } + // This goes from that -> suite:a,b:c,d:* + return getOrInsertSubtree('', tree, () => { + const query = new TestQueryMultiCase( + tree.query.suite, + tree.query.filePathParts, + subqueryTest, + {} + ); + assert(subqueryTest.length > 0, 'subqueryTest is empty'); + return { + readableRelativeName: subqueryTest[subqueryTest.length - 1] + kBigSeparator + kWildcard, + kWildcard, + query, + testCreationStack, + collapsible: isCollapsible(query), + }; + }); +} + +function addLeafForCase( + tree: TestSubtree<TestQueryMultiTest>, + t: RunCase, + checkCollapsible: (sq: TestQuery) => boolean +): void { + const query = tree.query; + let name: string = ''; + const subqueryParams: TestParamsRW = {}; + + // To start, tree is suite:a,b:c,d:* + // This loop goes from that -> suite:a,b:c,d:x=1;* -> suite:a,b:c,d:x=1;y=2;* + for (const [k, v] of Object.entries(t.id.params)) { + name = stringifySingleParam(k, v); + subqueryParams[k] = v; + + tree = getOrInsertSubtree(name, tree, () => { + const subquery = new TestQueryMultiCase( + query.suite, + query.filePathParts, + query.testPathParts, + subqueryParams + ); + return { + readableRelativeName: name + kParamSeparator + kWildcard, + query: subquery, + collapsible: checkCollapsible(subquery), + }; + }); + } + + // This goes from that -> suite:a,b:c,d:x=1;y=2 + const subquery = new TestQuerySingleCase( + query.suite, + query.filePathParts, + query.testPathParts, + subqueryParams + ); + checkCollapsible(subquery); // mark seenSubqueriesToExpand + insertLeaf(tree, subquery, t); +} + +function getOrInsertSubtree<T extends TestQuery>( + key: string, + parent: TestSubtree, + createSubtree: () => Omit<TestSubtree<T>, 'children'> +): TestSubtree<T> { + let v: TestSubtree<T>; + const child = parent.children.get(key); + if (child !== undefined) { + assert('children' in child); // Make sure cached subtree is not actually a leaf + v = child as TestSubtree<T>; + } else { + v = { ...createSubtree(), children: new Map() }; + parent.children.set(key, v); + } + return v; +} + +function insertLeaf(parent: TestSubtree, query: TestQuerySingleCase, t: RunCase) { + const leaf: TestTreeLeaf = { + readableRelativeName: readableNameForCase(query), + query, + run: (rec, expectations) => t.run(rec, query, expectations || []), + isUnimplemented: t.isUnimplemented, + }; + + // This is a leaf (e.g. s:f:t:x=1;* -> s:f:t:x=1). The key is always ''. + const key = ''; + assert(!parent.children.has(key), `Duplicate testcase: ${query}`); + parent.children.set(key, leaf); +} + +function dissolveSingleChildTrees(tree: TestTreeNode): TestTreeNode { + if ('children' in tree) { + const shouldDissolveThisTree = + tree.children.size === 1 && tree.query.depthInLevel !== 0 && tree.description === undefined; + if (shouldDissolveThisTree) { + // Loops exactly once + for (const [, child] of tree.children) { + // Recurse on child + return dissolveSingleChildTrees(child); + } + } + + for (const [k, child] of tree.children) { + // Recurse on each child + const newChild = dissolveSingleChildTrees(child); + if (newChild !== child) { + tree.children.set(k, newChild); + } + } + } + return tree; +} + +/** Generate a readable relative name for a case (used in standalone). */ +function readableNameForCase(query: TestQuerySingleCase): string { + const paramsKeys = Object.keys(query.params); + if (paramsKeys.length === 0) { + return query.testPathParts[query.testPathParts.length - 1] + kBigSeparator; + } else { + const lastKey = paramsKeys[paramsKeys.length - 1]; + return stringifySingleParam(lastKey, query.params[lastKey]); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/util.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/util.ts new file mode 100644 index 0000000000..37a5db3568 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/util.ts @@ -0,0 +1,10 @@ +/** + * Error without a stack, which can be used to fatally exit from `tool/` scripts with a + * user-friendly message (and no confusing stack). + */ +export class StacklessError extends Error { + constructor(message: string) { + super(message); + this.stack = undefined; + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/internal/version.ts b/dom/webgpu/tests/cts/checkout/src/common/internal/version.ts new file mode 100644 index 0000000000..53cc97482e --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/internal/version.ts @@ -0,0 +1 @@ +export const version = 'unknown'; diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/cmdline.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/cmdline.ts new file mode 100644 index 0000000000..463546c06d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/cmdline.ts @@ -0,0 +1,278 @@ +/* eslint no-console: "off" */ + +import * as fs from 'fs'; + +import { dataCache } from '../framework/data_cache.js'; +import { globalTestConfig } from '../framework/test_config.js'; +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { prettyPrintLog } from '../internal/logging/log_message.js'; +import { Logger } from '../internal/logging/logger.js'; +import { LiveTestCaseResult } from '../internal/logging/result.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { parseExpectationsForTestQuery } from '../internal/query/query.js'; +import { Colors } from '../util/colors.js'; +import { setGPUProvider } from '../util/navigator_gpu.js'; +import { assert, unreachable } from '../util/util.js'; + +import sys from './helper/sys.js'; + +function usage(rc: number): never { + console.log(`Usage: + tools/run_${sys.type} [OPTIONS...] QUERIES... + tools/run_${sys.type} 'unittests:*' 'webgpu:buffers,*' +Options: + --colors Enable ANSI colors in output. + --coverage Emit coverage data. + --verbose Print result/log of every test as it runs. + --list Print all testcase names that match the given query and exit. + --debug Include debug messages in logging. + --print-json Print the complete result JSON in the output. + --expectations Path to expectations file. + --gpu-provider Path to node module that provides the GPU implementation. + --gpu-provider-flag Flag to set on the gpu-provider as <flag>=<value> + --unroll-const-eval-loops Unrolls loops in constant-evaluation shader execution tests + --quiet Suppress summary information in output +`); + return sys.exit(rc); +} + +// The interface that exposes creation of the GPU, and optional interface to code coverage. +interface GPUProviderModule { + // @returns a GPU with the given flags + create(flags: string[]): GPU; + // An optional interface to a CodeCoverageProvider + coverage?: CodeCoverageProvider; +} + +interface CodeCoverageProvider { + // Starts collecting code coverage + begin(): void; + // Ends collecting of code coverage, returning the coverage data. + // This data is opaque (implementation defined). + end(): string; +} + +type listModes = 'none' | 'cases' | 'unimplemented'; + +Colors.enabled = false; + +let verbose = false; +let emitCoverage = false; +let listMode: listModes = 'none'; +let debug = false; +let printJSON = false; +let quiet = false; +let loadWebGPUExpectations: Promise<unknown> | undefined = undefined; +let gpuProviderModule: GPUProviderModule | undefined = undefined; +let dataPath: string | undefined = undefined; + +const queries: string[] = []; +const gpuProviderFlags: string[] = []; +for (let i = 0; i < sys.args.length; ++i) { + const a = sys.args[i]; + if (a.startsWith('-')) { + if (a === '--colors') { + Colors.enabled = true; + } else if (a === '--coverage') { + emitCoverage = true; + } else if (a === '--verbose') { + verbose = true; + } else if (a === '--list') { + listMode = 'cases'; + } else if (a === '--list-unimplemented') { + listMode = 'unimplemented'; + } else if (a === '--debug') { + debug = true; + } else if (a === '--data') { + dataPath = sys.args[++i]; + } else if (a === '--print-json') { + printJSON = true; + } else if (a === '--expectations') { + const expectationsFile = new URL(sys.args[++i], `file://${sys.cwd()}`).pathname; + loadWebGPUExpectations = import(expectationsFile).then(m => m.expectations); + } else if (a === '--gpu-provider') { + const modulePath = sys.args[++i]; + gpuProviderModule = require(modulePath); + } else if (a === '--gpu-provider-flag') { + gpuProviderFlags.push(sys.args[++i]); + } else if (a === '--quiet') { + quiet = true; + } else if (a === '--unroll-const-eval-loops') { + globalTestConfig.unrollConstEvalLoops = true; + } else { + console.log('unrecognized flag: ', a); + usage(1); + } + } else { + queries.push(a); + } +} + +let codeCoverage: CodeCoverageProvider | undefined = undefined; + +if (gpuProviderModule) { + setGPUProvider(() => gpuProviderModule!.create(gpuProviderFlags)); + if (emitCoverage) { + codeCoverage = gpuProviderModule.coverage; + if (codeCoverage === undefined) { + console.error( + `--coverage specified, but the GPUProviderModule does not support code coverage. +Did you remember to build with code coverage instrumentation enabled?` + ); + sys.exit(1); + } + } +} + +if (dataPath !== undefined) { + dataCache.setStore({ + load: (path: string) => { + return new Promise<string>((resolve, reject) => { + fs.readFile(`${dataPath}/${path}`, 'utf8', (err, data) => { + if (err !== null) { + reject(err.message); + } else { + resolve(data); + } + }); + }); + }, + }); +} +if (verbose) { + dataCache.setDebugLogger(console.log); +} + +if (queries.length === 0) { + console.log('no queries specified'); + usage(0); +} + +(async () => { + const loader = new DefaultTestFileLoader(); + assert(queries.length === 1, 'currently, there must be exactly one query on the cmd line'); + const filterQuery = parseQuery(queries[0]); + const testcases = await loader.loadCases(filterQuery); + const expectations = parseExpectationsForTestQuery( + await (loadWebGPUExpectations ?? []), + filterQuery + ); + + Logger.globalDebugMode = debug; + const log = new Logger(); + + const failed: Array<[string, LiveTestCaseResult]> = []; + const warned: Array<[string, LiveTestCaseResult]> = []; + const skipped: Array<[string, LiveTestCaseResult]> = []; + + let total = 0; + + if (codeCoverage !== undefined) { + codeCoverage.begin(); + } + + for (const testcase of testcases) { + const name = testcase.query.toString(); + switch (listMode) { + case 'cases': + console.log(name); + continue; + case 'unimplemented': + if (testcase.isUnimplemented) { + console.log(name); + } + continue; + default: + break; + } + + const [rec, res] = log.record(name); + await testcase.run(rec, expectations); + + if (verbose) { + printResults([[name, res]]); + } + + total++; + switch (res.status) { + case 'pass': + break; + case 'fail': + failed.push([name, res]); + break; + case 'warn': + warned.push([name, res]); + break; + case 'skip': + skipped.push([name, res]); + break; + default: + unreachable('unrecognized status'); + } + } + + if (codeCoverage !== undefined) { + const coverage = codeCoverage.end(); + console.log(`Code-coverage: [[${coverage}]]`); + } + + if (listMode !== 'none') { + return; + } + + assert(total > 0, 'found no tests!'); + + // MAINTENANCE_TODO: write results out somewhere (a file?) + if (printJSON) { + console.log(log.asJSON(2)); + } + + if (!quiet) { + if (skipped.length) { + console.log(''); + console.log('** Skipped **'); + printResults(skipped); + } + if (warned.length) { + console.log(''); + console.log('** Warnings **'); + printResults(warned); + } + if (failed.length) { + console.log(''); + console.log('** Failures **'); + printResults(failed); + } + + const passed = total - warned.length - failed.length - skipped.length; + const pct = (x: number) => ((100 * x) / total).toFixed(2); + const rpt = (x: number) => { + const xs = x.toString().padStart(1 + Math.log10(total), ' '); + return `${xs} / ${total} = ${pct(x).padStart(6, ' ')}%`; + }; + console.log(''); + console.log(`** Summary ** +Passed w/o warnings = ${rpt(passed)} +Passed with warnings = ${rpt(warned.length)} +Skipped = ${rpt(skipped.length)} +Failed = ${rpt(failed.length)}`); + } + + if (failed.length || warned.length) { + sys.exit(1); + } +})().catch(ex => { + console.log(ex.stack ?? ex.toString()); + sys.exit(1); +}); + +function printResults(results: Array<[string, LiveTestCaseResult]>): void { + for (const [name, r] of results) { + console.log(`[${r.status}] ${name} (${r.timems}ms). Log:`); + if (r.logs) { + for (const l of r.logs) { + console.log(prettyPrintLog(l)); + } + } + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/options.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/options.ts new file mode 100644 index 0000000000..bec14694a3 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/options.ts @@ -0,0 +1,22 @@ +let windowURL: URL | undefined = undefined; +function getWindowURL() { + if (windowURL === undefined) { + windowURL = new URL(window.location.toString()); + } + return windowURL; +} + +export function optionEnabled( + opt: string, + searchParams: URLSearchParams = getWindowURL().searchParams +): boolean { + const val = searchParams.get(opt); + return val !== null && val !== '0'; +} + +export function optionString( + opt: string, + searchParams: URLSearchParams = getWindowURL().searchParams +): string { + return searchParams.get(opt) || ''; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/sys.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/sys.ts new file mode 100644 index 0000000000..d2e07ff26d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/sys.ts @@ -0,0 +1,46 @@ +/* eslint no-process-exit: "off" */ +/* eslint @typescript-eslint/no-namespace: "off" */ + +function node() { + const { existsSync } = require('fs'); + + return { + type: 'node', + existsSync, + args: process.argv.slice(2), + cwd: () => process.cwd(), + exit: (code?: number | undefined) => process.exit(code), + }; +} + +declare global { + namespace Deno { + function readFileSync(path: string): Uint8Array; + const args: string[]; + const cwd: () => string; + function exit(code?: number): never; + } +} + +function deno() { + function existsSync(path: string) { + try { + Deno.readFileSync(path); + return true; + } catch (err) { + return false; + } + } + + return { + type: 'deno', + existsSync, + args: Deno.args, + cwd: Deno.cwd, + exit: Deno.exit, + }; +} + +const sys = typeof globalThis.process !== 'undefined' ? node() : deno(); + +export default sys; diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker-worker.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker-worker.ts new file mode 100644 index 0000000000..9af555f36d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker-worker.ts @@ -0,0 +1,32 @@ +import { setBaseResourcePath } from '../../framework/resources.js'; +import { DefaultTestFileLoader } from '../../internal/file_loader.js'; +import { Logger } from '../../internal/logging/logger.js'; +import { parseQuery } from '../../internal/query/parseQuery.js'; +import { TestQueryWithExpectation } from '../../internal/query/query.js'; +import { assert } from '../../util/util.js'; + +// Should be DedicatedWorkerGlobalScope, but importing lib "webworker" conflicts with lib "dom". +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +declare const self: any; + +const loader = new DefaultTestFileLoader(); + +setBaseResourcePath('../../../resources'); + +self.onmessage = async (ev: MessageEvent) => { + const query: string = ev.data.query; + const expectations: TestQueryWithExpectation[] = ev.data.expectations; + const debug: boolean = ev.data.debug; + + Logger.globalDebugMode = debug; + const log = new Logger(); + + const testcases = Array.from(await loader.loadCases(parseQuery(query))); + assert(testcases.length === 1, 'worker query resulted in != 1 cases'); + + const testcase = testcases[0]; + const [rec, result] = log.record(testcase.query.toString()); + await testcase.run(rec, expectations); + + self.postMessage({ query, result }); +}; diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker.ts new file mode 100644 index 0000000000..2ddc3a951b --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/helper/test_worker.ts @@ -0,0 +1,44 @@ +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'; + +export class TestWorker { + private readonly debug: boolean; + private readonly worker: Worker; + private readonly resolvers = new Map<string, (result: LiveTestCaseResult) => void>(); + + constructor(debug: boolean) { + this.debug = debug; + + const selfPath = import.meta.url; + const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); + const workerPath = selfPathDir + '/test_worker-worker.js'; + this.worker = new Worker(workerPath, { type: 'module' }); + this.worker.onmessage = ev => { + const query: string = ev.data.query; + const result: TransferredTestCaseResult = ev.data.result; + if (result.logs) { + for (const l of result.logs) { + Object.setPrototypeOf(l, LogMessageWithStack.prototype); + } + } + this.resolvers.get(query)!(result as LiveTestCaseResult); + + // 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). + }; + } + + async run( + rec: TestCaseRecorder, + query: string, + expectations: TestQueryWithExpectation[] = [] + ): Promise<void> { + this.worker.postMessage({ query, expectations, debug: this.debug }); + const workerResult = await new Promise<LiveTestCaseResult>(resolve => { + this.resolvers.set(query, resolve); + }); + rec.injectResult(workerResult); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/server.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/server.ts new file mode 100644 index 0000000000..350a864a34 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/server.ts @@ -0,0 +1,227 @@ +/* eslint no-console: "off" */ + +import * as fs from 'fs'; +import * as http from 'http'; +import { AddressInfo } from 'net'; + +import { dataCache } from '../framework/data_cache.js'; +import { globalTestConfig } from '../framework/test_config.js'; +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { prettyPrintLog } from '../internal/logging/log_message.js'; +import { Logger } from '../internal/logging/logger.js'; +import { LiveTestCaseResult, Status } from '../internal/logging/result.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { TestQueryWithExpectation } from '../internal/query/query.js'; +import { TestTreeLeaf } from '../internal/tree.js'; +import { Colors } from '../util/colors.js'; +import { setGPUProvider } from '../util/navigator_gpu.js'; + +import sys from './helper/sys.js'; + +function usage(rc: number): never { + console.log(`Usage: + tools/run_${sys.type} [OPTIONS...] +Options: + --colors Enable ANSI colors in output. + --coverage Add coverage data to each result. + --data Path to the data cache directory. + --verbose Print result/log of every test as it runs. + --gpu-provider Path to node module that provides the GPU implementation. + --gpu-provider-flag Flag to set on the gpu-provider as <flag>=<value> + --unroll-const-eval-loops Unrolls loops in constant-evaluation shader execution tests + --u Flag to set on the gpu-provider as <flag>=<value> + +Provides an HTTP server used for running tests via an HTTP RPC interface +To run a test, perform an HTTP GET or POST at the URL: + http://localhost:port/run?<test-name> +To shutdown the server perform an HTTP GET or POST at the URL: + http://localhost:port/terminate +`); + return sys.exit(rc); +} + +interface RunResult { + // The result of the test + status: Status; + // Any additional messages printed + message: string; + // Code coverage data, if the server was started with `--coverage` + // This data is opaque (implementation defined). + coverageData?: string; +} + +// The interface that exposes creation of the GPU, and optional interface to code coverage. +interface GPUProviderModule { + // @returns a GPU with the given flags + create(flags: string[]): GPU; + // An optional interface to a CodeCoverageProvider + coverage?: CodeCoverageProvider; +} + +interface CodeCoverageProvider { + // Starts collecting code coverage + begin(): void; + // Ends collecting of code coverage, returning the coverage data. + // This data is opaque (implementation defined). + end(): string; +} + +if (!sys.existsSync('src/common/runtime/cmdline.ts')) { + console.log('Must be run from repository root'); + usage(1); +} + +Colors.enabled = false; + +let emitCoverage = false; +let verbose = false; +let gpuProviderModule: GPUProviderModule | undefined = undefined; +let dataPath: string | undefined = undefined; + +const gpuProviderFlags: string[] = []; +for (let i = 0; i < sys.args.length; ++i) { + const a = sys.args[i]; + if (a.startsWith('-')) { + if (a === '--colors') { + Colors.enabled = true; + } else if (a === '--coverage') { + emitCoverage = true; + } else if (a === '--data') { + dataPath = sys.args[++i]; + } else if (a === '--gpu-provider') { + const modulePath = sys.args[++i]; + gpuProviderModule = require(modulePath); + } else if (a === '--gpu-provider-flag') { + gpuProviderFlags.push(sys.args[++i]); + } else if (a === '--unroll-const-eval-loops') { + globalTestConfig.unrollConstEvalLoops = true; + } else if (a === '--help') { + usage(1); + } else if (a === '--verbose') { + verbose = true; + } else { + console.log(`unrecognised flag: ${a}`); + } + } +} + +let codeCoverage: CodeCoverageProvider | undefined = undefined; + +if (gpuProviderModule) { + setGPUProvider(() => gpuProviderModule!.create(gpuProviderFlags)); + + if (emitCoverage) { + codeCoverage = gpuProviderModule.coverage; + if (codeCoverage === undefined) { + console.error( + `--coverage specified, but the GPUProviderModule does not support code coverage. +Did you remember to build with code coverage instrumentation enabled?` + ); + sys.exit(1); + } + } +} + +if (dataPath !== undefined) { + dataCache.setStore({ + load: (path: string) => { + return new Promise<string>((resolve, reject) => { + fs.readFile(`${dataPath}/${path}`, 'utf8', (err, data) => { + if (err !== null) { + reject(err.message); + } else { + resolve(data); + } + }); + }); + }, + }); +} +if (verbose) { + dataCache.setDebugLogger(console.log); +} + +(async () => { + Logger.globalDebugMode = verbose; + const log = new Logger(); + const testcases = new Map<string, TestTreeLeaf>(); + + async function runTestcase( + testcase: TestTreeLeaf, + expectations: TestQueryWithExpectation[] = [] + ): Promise<LiveTestCaseResult> { + const name = testcase.query.toString(); + const [rec, res] = log.record(name); + await testcase.run(rec, expectations); + return res; + } + + const server = http.createServer( + async (request: http.IncomingMessage, response: http.ServerResponse) => { + if (request.url === undefined) { + response.end('invalid url'); + return; + } + + const loadCasesPrefix = '/load?'; + const runPrefix = '/run?'; + const terminatePrefix = '/terminate'; + + if (request.url.startsWith(loadCasesPrefix)) { + const query = request.url.substr(loadCasesPrefix.length); + try { + const webgpuQuery = parseQuery(query); + const loader = new DefaultTestFileLoader(); + for (const testcase of await loader.loadCases(webgpuQuery)) { + testcases.set(testcase.query.toString(), testcase); + } + response.statusCode = 200; + response.end(); + } catch (err) { + response.statusCode = 500; + response.end(`load failed with error: ${err}\n${(err as Error).stack}`); + } + } else if (request.url.startsWith(runPrefix)) { + const name = request.url.substr(runPrefix.length); + try { + const testcase = testcases.get(name); + if (testcase) { + if (codeCoverage !== undefined) { + codeCoverage.begin(); + } + const result = await runTestcase(testcase); + const coverageData = codeCoverage !== undefined ? codeCoverage.end() : undefined; + let message = ''; + if (result.logs !== undefined) { + message = result.logs.map(log => prettyPrintLog(log)).join('\n'); + } + const status = result.status; + const res: RunResult = { status, message, coverageData }; + response.statusCode = 200; + response.end(JSON.stringify(res)); + } else { + response.statusCode = 404; + response.end(`test case '${name}' not found`); + } + } catch (err) { + response.statusCode = 500; + response.end(`run failed with error: ${err}`); + } + } else if (request.url.startsWith(terminatePrefix)) { + server.close(); + sys.exit(1); + } else { + response.statusCode = 404; + response.end('unhandled url request'); + } + } + ); + + server.listen(0, () => { + const address = server.address() as AddressInfo; + console.log(`Server listening at [[${address.port}]]`); + }); +})().catch(ex => { + console.error(ex.stack ?? ex.toString()); + sys.exit(1); +}); diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/standalone.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/standalone.ts new file mode 100644 index 0000000000..0dd158fd68 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/standalone.ts @@ -0,0 +1,625 @@ +// Implements the standalone test runner (see also: /standalone/index.html). + +import { dataCache } from '../framework/data_cache.js'; +import { setBaseResourcePath } from '../framework/resources.js'; +import { globalTestConfig } from '../framework/test_config.js'; +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { Logger } from '../internal/logging/logger.js'; +import { LiveTestCaseResult } from '../internal/logging/result.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { TestQueryLevel } from '../internal/query/query.js'; +import { TestTreeNode, TestSubtree, TestTreeLeaf, TestTree } from '../internal/tree.js'; +import { setDefaultRequestAdapterOptions } from '../util/navigator_gpu.js'; +import { assert, ErrorWithExtra, unreachable } from '../util/util.js'; + +import { optionEnabled, optionString } from './helper/options.js'; +import { TestWorker } from './helper/test_worker.js'; + +window.onbeforeunload = () => { + // Prompt user before reloading if there are any results + return haveSomeResults ? false : undefined; +}; + +let haveSomeResults = false; + +// The possible options for the tests. +interface StandaloneOptions { + runnow: boolean; + worker: boolean; + debug: boolean; + unrollConstEvalLoops: boolean; + powerPreference: string; +} + +// Extra per option info. +interface StandaloneOptionInfo { + description: string; + parser?: (key: string) => boolean | string; + selectValueDescriptions?: { value: string; description: string }[]; +} + +// Type for info for every option. This definition means adding an option +// will generate a compile time error if not extra info is provided. +type StandaloneOptionsInfos = Record<keyof StandaloneOptions, StandaloneOptionInfo>; + +const optionsInfo: StandaloneOptionsInfos = { + runnow: { description: 'run immediately on load' }, + worker: { description: 'run in a worker' }, + debug: { description: 'show more info' }, + unrollConstEvalLoops: { description: 'unroll const eval loops in WGSL' }, + powerPreference: { + description: 'set default powerPreference for some tests', + parser: optionString, + selectValueDescriptions: [ + { value: '', description: 'default' }, + { value: 'low-power', description: 'low-power' }, + { value: 'high-performance', description: 'high-performance' }, + ], + }, +}; + +/** + * Converts camel case to snake case. + * Examples: + * fooBar -> foo_bar + * parseHTMLFile -> parse_html_file + */ +function camelCaseToSnakeCase(id: string) { + return id + .replace(/(.)([A-Z][a-z]+)/g, '$1_$2') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); +} + +/** + * Creates a StandaloneOptions from the current URL search parameters. + */ +function getOptionsInfoFromSearchParameters( + optionsInfos: StandaloneOptionsInfos +): StandaloneOptions { + const optionValues: Record<string, boolean | string> = {}; + for (const [optionName, info] of Object.entries(optionsInfos)) { + const parser = info.parser || optionEnabled; + optionValues[optionName] = parser(camelCaseToSnakeCase(optionName)); + } + return (optionValues as unknown) as StandaloneOptions; +} + +// This is just a cast in one place. +function optionsToRecord(options: StandaloneOptions) { + return (options as unknown) as Record<string, boolean | string>; +} + +const options = getOptionsInfoFromSearchParameters(optionsInfo); +const { runnow, debug, unrollConstEvalLoops, powerPreference } = options; +globalTestConfig.unrollConstEvalLoops = unrollConstEvalLoops; + +Logger.globalDebugMode = debug; +const logger = new Logger(); + +setBaseResourcePath('../out/resources'); + +const worker = options.worker ? new TestWorker(debug) : undefined; + +const autoCloseOnPass = document.getElementById('autoCloseOnPass') as HTMLInputElement; +const resultsVis = document.getElementById('resultsVis')!; +const progressElem = document.getElementById('progress')!; +const progressTestNameElem = progressElem.querySelector('.progress-test-name')!; +const stopButtonElem = progressElem.querySelector('button')!; +let runDepth = 0; +let stopRequested = false; + +stopButtonElem.addEventListener('click', () => { + stopRequested = true; +}); + +if (powerPreference) { + setDefaultRequestAdapterOptions({ powerPreference: powerPreference as GPUPowerPreference }); +} + +dataCache.setStore({ + load: async (path: string) => { + const response = await fetch(`data/${path}`); + if (!response.ok) { + return Promise.reject(response.statusText); + } + return await response.text(); + }, +}); + +interface SubtreeResult { + pass: number; + fail: number; + warn: number; + skip: number; + total: number; + timems: number; +} + +function emptySubtreeResult() { + return { pass: 0, fail: 0, warn: 0, skip: 0, total: 0, timems: 0 }; +} + +function mergeSubtreeResults(...results: SubtreeResult[]) { + const target = emptySubtreeResult(); + for (const result of results) { + target.pass += result.pass; + target.fail += result.fail; + target.warn += result.warn; + target.skip += result.skip; + target.total += result.total; + target.timems += result.timems; + } + return target; +} + +type SetCheckedRecursively = () => void; +type GenerateSubtreeHTML = (parent: HTMLElement) => SetCheckedRecursively; +type RunSubtree = () => Promise<SubtreeResult>; + +interface VisualizedSubtree { + generateSubtreeHTML: GenerateSubtreeHTML; + runSubtree: RunSubtree; +} + +// DOM generation + +function memoize<T>(fn: () => T): () => T { + let value: T | undefined; + return () => { + if (value === undefined) { + value = fn(); + } + return value; + }; +} + +function makeTreeNodeHTML(tree: TestTreeNode, parentLevel: TestQueryLevel): VisualizedSubtree { + let subtree: VisualizedSubtree; + + if ('children' in tree) { + subtree = makeSubtreeHTML(tree, parentLevel); + } else { + subtree = makeCaseHTML(tree); + } + + const generateMyHTML = (parentElement: HTMLElement) => { + const div = $('<div>').appendTo(parentElement)[0]; + return subtree.generateSubtreeHTML(div); + }; + return { runSubtree: subtree.runSubtree, generateSubtreeHTML: generateMyHTML }; +} + +function makeCaseHTML(t: TestTreeLeaf): VisualizedSubtree { + // Becomes set once the case has been run once. + let caseResult: LiveTestCaseResult | undefined; + + // Becomes set once the DOM for this case exists. + let clearRenderedResult: (() => void) | undefined; + let updateRenderedResult: (() => void) | undefined; + + const name = t.query.toString(); + const runSubtree = async () => { + if (clearRenderedResult) clearRenderedResult(); + + const result: SubtreeResult = emptySubtreeResult(); + progressTestNameElem.textContent = name; + + haveSomeResults = true; + const [rec, res] = logger.record(name); + caseResult = res; + if (worker) { + await worker.run(rec, name); + } else { + await t.run(rec); + } + + result.total++; + result.timems += caseResult.timems; + switch (caseResult.status) { + case 'pass': + result.pass++; + break; + case 'fail': + result.fail++; + break; + case 'skip': + result.skip++; + break; + case 'warn': + result.warn++; + break; + default: + unreachable(); + } + + if (updateRenderedResult) updateRenderedResult(); + + return result; + }; + + const generateSubtreeHTML = (div: HTMLElement) => { + div.classList.add('testcase'); + + const caselogs = $('<div>').addClass('testcaselogs').hide(); + const [casehead, setChecked] = makeTreeNodeHeaderHTML(t, runSubtree, 2, checked => { + checked ? caselogs.show() : caselogs.hide(); + }); + const casetime = $('<div>').addClass('testcasetime').html('ms').appendTo(casehead); + div.appendChild(casehead); + div.appendChild(caselogs[0]); + + clearRenderedResult = () => { + div.removeAttribute('data-status'); + casetime.text('ms'); + caselogs.empty(); + }; + + updateRenderedResult = () => { + if (caseResult) { + div.setAttribute('data-status', caseResult.status); + + casetime.text(caseResult.timems.toFixed(4) + ' ms'); + + if (caseResult.logs) { + caselogs.empty(); + for (const l of caseResult.logs) { + const caselog = $('<div>').addClass('testcaselog').appendTo(caselogs); + $('<button>') + .addClass('testcaselogbtn') + .attr('alt', 'Log stack to console') + .attr('title', 'Log stack to console') + .appendTo(caselog) + .on('click', () => { + consoleLogError(l); + }); + $('<pre>').addClass('testcaselogtext').appendTo(caselog).text(l.toJSON()); + } + } + } + }; + + updateRenderedResult(); + + return setChecked; + }; + + return { runSubtree, generateSubtreeHTML }; +} + +function makeSubtreeHTML(n: TestSubtree, parentLevel: TestQueryLevel): VisualizedSubtree { + let subtreeResult: SubtreeResult = emptySubtreeResult(); + // Becomes set once the DOM for this case exists. + let clearRenderedResult: (() => void) | undefined; + let updateRenderedResult: (() => void) | undefined; + + const { runSubtree, generateSubtreeHTML } = makeSubtreeChildrenHTML( + n.children.values(), + n.query.level + ); + + const runMySubtree = async () => { + if (runDepth === 0) { + stopRequested = false; + progressElem.style.display = ''; + } + if (stopRequested) { + const result = emptySubtreeResult(); + result.skip = 1; + result.total = 1; + return result; + } + + ++runDepth; + + if (clearRenderedResult) clearRenderedResult(); + subtreeResult = await runSubtree(); + if (updateRenderedResult) updateRenderedResult(); + + --runDepth; + if (runDepth === 0) { + progressElem.style.display = 'none'; + } + + return subtreeResult; + }; + + const generateMyHTML = (div: HTMLElement) => { + const subtreeHTML = $('<div>').addClass('subtreechildren'); + const generateSubtree = memoize(() => generateSubtreeHTML(subtreeHTML[0])); + + // Hide subtree - it's not generated yet. + subtreeHTML.hide(); + const [header, setChecked] = makeTreeNodeHeaderHTML(n, runMySubtree, parentLevel, checked => { + if (checked) { + // Make sure the subtree is generated and then show it. + generateSubtree(); + subtreeHTML.show(); + } else { + subtreeHTML.hide(); + } + }); + + div.classList.add('subtree'); + div.classList.add(['', 'multifile', 'multitest', 'multicase'][n.query.level]); + div.appendChild(header); + div.appendChild(subtreeHTML[0]); + + clearRenderedResult = () => { + div.removeAttribute('data-status'); + }; + + updateRenderedResult = () => { + let status = ''; + if (subtreeResult.pass > 0) { + status += 'pass'; + } + if (subtreeResult.fail > 0) { + status += 'fail'; + } + div.setAttribute('data-status', status); + if (autoCloseOnPass.checked && status === 'pass') { + div.firstElementChild!.removeAttribute('open'); + } + }; + + updateRenderedResult(); + + return () => { + setChecked(); + const setChildrenChecked = generateSubtree(); + setChildrenChecked(); + }; + }; + + return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML }; +} + +function makeSubtreeChildrenHTML( + children: Iterable<TestTreeNode>, + parentLevel: TestQueryLevel +): VisualizedSubtree { + const childFns = Array.from(children, subtree => makeTreeNodeHTML(subtree, parentLevel)); + + const runMySubtree = async () => { + const results: SubtreeResult[] = []; + for (const { runSubtree } of childFns) { + results.push(await runSubtree()); + } + return mergeSubtreeResults(...results); + }; + const generateMyHTML = (div: HTMLElement) => { + const setChildrenChecked = Array.from(childFns, ({ generateSubtreeHTML }) => + generateSubtreeHTML(div) + ); + + return () => { + for (const setChildChecked of setChildrenChecked) { + setChildChecked(); + } + }; + }; + + return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML }; +} + +function consoleLogError(e: Error | ErrorWithExtra | undefined) { + if (e === undefined) return; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + (globalThis as any)._stack = e; + /* eslint-disable-next-line no-console */ + console.log('_stack =', e); + if ('extra' in e && e.extra !== undefined) { + /* eslint-disable-next-line no-console */ + console.log('_stack.extra =', e.extra); + } +} + +function makeTreeNodeHeaderHTML( + n: TestTreeNode, + runSubtree: RunSubtree, + parentLevel: TestQueryLevel, + onChange: (checked: boolean) => void +): [HTMLElement, SetCheckedRecursively] { + const isLeaf = 'run' in n; + const div = $('<details>').addClass('nodeheader'); + const header = $('<summary>').appendTo(div); + + const setChecked = () => { + div.prop('open', true); // (does not fire onChange) + onChange(true); + }; + + const href = `?${worker ? 'worker&' : ''}${debug ? 'debug&' : ''}q=${n.query.toString()}`; + if (onChange) { + div.on('toggle', function (this) { + onChange((this as HTMLDetailsElement).open); + }); + + // Expand the shallower parts of the tree at load. + // Also expand completely within subtrees that are at the same query level + // (e.g. s:f:t,* and s:f:t,t,*). + if (n.query.level <= lastQueryLevelToExpand || n.query.level === parentLevel) { + setChecked(); + } + } + const runtext = isLeaf ? 'Run case' : 'Run subtree'; + $('<button>') + .addClass(isLeaf ? 'leafrun' : 'subtreerun') + .attr('alt', runtext) + .attr('title', runtext) + .on('click', () => void runSubtree()) + .appendTo(header); + $('<a>') + .addClass('nodelink') + .attr('href', href) + .attr('alt', 'Open') + .attr('title', 'Open') + .appendTo(header); + if ('testCreationStack' in n && n.testCreationStack) { + $('<button>') + .addClass('testcaselogbtn') + .attr('alt', 'Log test creation stack to console') + .attr('title', 'Log test creation stack to console') + .appendTo(header) + .on('click', () => { + consoleLogError(n.testCreationStack); + }); + } + const nodetitle = $('<div>').addClass('nodetitle').appendTo(header); + const nodecolumns = $('<span>').addClass('nodecolumns').appendTo(nodetitle); + { + $('<input>') + .attr('type', 'text') + .prop('readonly', true) + .addClass('nodequery') + .val(n.query.toString()) + .appendTo(nodecolumns); + if (n.subtreeCounts) { + $('<span>') + .attr('title', '(Nodes with TODOs) / (Total test count)') + .text(TestTree.countsToString(n)) + .appendTo(nodecolumns); + } + } + if ('description' in n && n.description) { + nodetitle.append(' '); + $('<pre>') // + .addClass('nodedescription') + .text(n.description) + .appendTo(header); + } + return [div[0], setChecked]; +} + +// Collapse s:f:t:* or s:f:t:c by default. +let lastQueryLevelToExpand: TestQueryLevel = 2; + +type ParamValue = string | undefined | null | boolean | string[]; + +/** + * Takes an array of string, ParamValue and returns an array of pairs + * of [key, value] where value is a string. Converts boolean to '0' or '1'. + */ +function keyValueToPairs([k, v]: [string, ParamValue]): [string, string][] { + const key = camelCaseToSnakeCase(k); + if (typeof v === 'boolean') { + return [[key, v ? '1' : '0']]; + } else if (Array.isArray(v)) { + return v.map(v => [key, v]); + } else { + return [[key, v!.toString()]]; + } +} + +/** + * Converts key value pairs to a search string. + * Keys will appear in order in the search string. + * Values can be undefined, null, boolean, string, or string[] + * If the value is falsy the key will not appear in the search string. + * If the value is an array the key will appear multiple times. + * + * @param params Some object with key value pairs. + * @returns a search string. + */ +function prepareParams(params: Record<string, ParamValue>): string { + const pairsArrays = Object.entries(params) + .filter(([, v]) => !!v) + .map(keyValueToPairs); + const pairs = pairsArrays.flat(); + return new URLSearchParams(pairs).toString(); +} + +void (async () => { + const loader = new DefaultTestFileLoader(); + + // MAINTENANCE_TODO: start populating page before waiting for everything to load? + const qs = new URLSearchParams(window.location.search).getAll('q'); + if (qs.length === 0) { + qs.push('webgpu:*'); + } + + // Update the URL bar to match the exact current options. + const updateURLWithCurrentOptions = () => { + const search = prepareParams(optionsToRecord(options)); + let url = `${window.location.origin}${window.location.pathname}`; + // Add in q separately to avoid escaping punctuation marks. + url += `?${search}${search ? '&' : ''}${qs.map(q => 'q=' + q).join('&')}`; + window.history.replaceState(null, '', url.toString()); + }; + updateURLWithCurrentOptions(); + + const addOptionsToPage = (options: StandaloneOptions, optionsInfos: StandaloneOptionsInfos) => { + const optionsElem = $('table#options>tbody')[0]; + const optionValues = optionsToRecord(options); + + const createCheckbox = (optionName: string) => { + return $(`<input>`) + .attr('type', 'checkbox') + .prop('checked', optionValues[optionName] as boolean) + .on('change', function () { + optionValues[optionName] = (this as HTMLInputElement).checked; + updateURLWithCurrentOptions(); + }); + }; + + const createSelect = (optionName: string, info: StandaloneOptionInfo) => { + const select = $('<select>').on('change', function () { + optionValues[optionName] = (this as HTMLInputElement).value; + updateURLWithCurrentOptions(); + }); + const currentValue = optionValues[optionName]; + for (const { value, description } of info.selectValueDescriptions!) { + $('<option>') + .text(description) + .val(value) + .prop('selected', value === currentValue) + .appendTo(select); + } + return select; + }; + + for (const [optionName, info] of Object.entries(optionsInfos)) { + const input = + typeof optionValues[optionName] === 'boolean' + ? createCheckbox(optionName) + : createSelect(optionName, info); + $('<tr>') + .append($('<td>').append(input)) + .append($('<td>').text(camelCaseToSnakeCase(optionName))) + .append($('<td>').text(info.description)) + .appendTo(optionsElem); + } + }; + addOptionsToPage(options, optionsInfo); + + assert(qs.length === 1, 'currently, there must be exactly one ?q='); + const rootQuery = parseQuery(qs[0]); + if (rootQuery.level > lastQueryLevelToExpand) { + lastQueryLevelToExpand = rootQuery.level; + } + loader.addEventListener('import', ev => { + $('#info')[0].textContent = `loading: ${ev.data.url}`; + }); + loader.addEventListener('finish', () => { + $('#info')[0].textContent = ''; + }); + const tree = await loader.loadTree(rootQuery); + + tree.dissolveSingleChildTrees(); + + const { runSubtree, generateSubtreeHTML } = makeSubtreeHTML(tree.root, 1); + const setTreeCheckedRecursively = generateSubtreeHTML(resultsVis); + + document.getElementById('expandall')!.addEventListener('click', () => { + setTreeCheckedRecursively(); + }); + + document.getElementById('copyResultsJSON')!.addEventListener('click', () => { + void navigator.clipboard.writeText(logger.asJSON(2)); + }); + + if (runnow) { + void runSubtree(); + } +})(); diff --git a/dom/webgpu/tests/cts/checkout/src/common/runtime/wpt.ts b/dom/webgpu/tests/cts/checkout/src/common/runtime/wpt.ts new file mode 100644 index 0000000000..2cb9f8dbf7 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/runtime/wpt.ts @@ -0,0 +1,83 @@ +// Implements the wpt-embedded test runner (see also: wpt/cts.https.html). + +import { globalTestConfig } from '../framework/test_config.js'; +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { prettyPrintLog } from '../internal/logging/log_message.js'; +import { Logger } from '../internal/logging/logger.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { parseExpectationsForTestQuery, relativeQueryString } from '../internal/query/query.js'; +import { assert } from '../util/util.js'; + +import { optionEnabled } from './helper/options.js'; +import { TestWorker } from './helper/test_worker.js'; + +// testharness.js API (https://web-platform-tests.org/writing-tests/testharness-api.html) +declare interface WptTestObject { + step(f: () => void): void; + done(): void; +} +declare function setup(properties: { explicit_done?: boolean }): void; +declare function promise_test(f: (t: WptTestObject) => Promise<void>, name: string): void; +declare function done(): void; +declare function assert_unreached(description: string): void; + +declare const loadWebGPUExpectations: Promise<unknown> | undefined; +declare const shouldWebGPUCTSFailOnWarnings: Promise<boolean> | undefined; + +setup({ + // It's convenient for us to asynchronously add tests to the page. Prevent done() from being + // called implicitly when the page is finished loading. + explicit_done: true, +}); + +void (async () => { + const workerEnabled = optionEnabled('worker'); + const worker = workerEnabled ? new TestWorker(false) : undefined; + + globalTestConfig.unrollConstEvalLoops = optionEnabled('unroll_const_eval_loops'); + + const failOnWarnings = + typeof shouldWebGPUCTSFailOnWarnings !== 'undefined' && (await shouldWebGPUCTSFailOnWarnings); + + const loader = new DefaultTestFileLoader(); + const qs = new URLSearchParams(window.location.search).getAll('q'); + assert(qs.length === 1, 'currently, there must be exactly one ?q='); + const filterQuery = parseQuery(qs[0]); + const testcases = await loader.loadCases(filterQuery); + + const expectations = + typeof loadWebGPUExpectations !== 'undefined' + ? parseExpectationsForTestQuery( + await loadWebGPUExpectations, + filterQuery, + new URL(window.location.href) + ) + : []; + + const log = new Logger(); + + for (const testcase of testcases) { + const name = testcase.query.toString(); + // For brevity, display the case name "relative" to the ?q= path. + const shortName = relativeQueryString(filterQuery, testcase.query) || '(case)'; + + const wpt_fn = async () => { + const [rec, res] = log.record(name); + if (worker) { + await worker.run(rec, name, expectations); + } else { + await testcase.run(rec, expectations); + } + + // Unfortunately, it seems not possible to surface any logs for warn/skip. + if (res.status === 'fail' || (res.status === 'warn' && failOnWarnings)) { + const logs = (res.logs ?? []).map(prettyPrintLog); + assert_unreached('\n' + logs.join('\n') + '\n'); + } + }; + + promise_test(wpt_fn, shortName); + } + + done(); +})(); diff --git a/dom/webgpu/tests/cts/checkout/src/common/templates/cts.https.html b/dom/webgpu/tests/cts/checkout/src/common/templates/cts.https.html new file mode 100644 index 0000000000..2961f0c3ee --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/templates/cts.https.html @@ -0,0 +1,32 @@ +<!-- + This test suite is built from the TypeScript sources at: + https://github.com/gpuweb/cts + + If you are debugging WebGPU conformance tests, it's highly recommended that + you use the standalone interactive runner in that repository, which + provides tools for easier debugging and editing (source maps, debug + logging, warn/skip functionality, etc.) + + NOTE: + The WPT version of this file is generated with *one variant per test spec + file*. If your harness needs more fine-grained suppressions, you'll need to + generate your own variants list from your suppression list. + See `tools/gen_wpt_cts_html` to do this. + + When run under browser CI, the original cts.https.html should be skipped, and + this alternate version should be run instead, under a non-exported WPT test + directory (e.g. Chromium's wpt_internal). +--> + +<!doctype html> +<title>WebGPU CTS</title> +<meta charset=utf-8> +<link rel=help href='https://gpuweb.github.io/gpuweb/'> + +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> + const loadWebGPUExpectations = undefined; + const shouldWebGPUCTSFailOnWarnings = undefined; +</script> +<script type=module src=/webgpu/common/runtime/wpt.js></script> diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/.eslintrc.json b/dom/webgpu/tests/cts/checkout/src/common/tools/.eslintrc.json new file mode 100644 index 0000000000..aed978d459 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "rules": { + "no-console": "off", + "no-process-exit": "off", + "node/no-unpublished-import": "off", + "node/no-unpublished-require": "off", + "@typescript-eslint/no-var-requires": "off" + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/checklist.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/checklist.ts new file mode 100644 index 0000000000..393990e26f --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/checklist.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as process from 'process'; + +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { Ordering, compareQueries } from '../internal/query/compare.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { TestQuery, TestQueryMultiFile } from '../internal/query/query.js'; +import { loadTreeForQuery, TestTree } from '../internal/tree.js'; +import { StacklessError } from '../internal/util.js'; +import { assert } from '../util/util.js'; + +function usage(rc: number): void { + console.error('Usage:'); + console.error(' tools/checklist FILE'); + console.error(' tools/checklist my/list.txt'); + process.exit(rc); +} + +if (process.argv.length === 2) usage(0); +if (process.argv.length !== 3) usage(1); + +type QueryInSuite = { readonly query: TestQuery; readonly done: boolean }; +type QueriesInSuite = QueryInSuite[]; +type QueriesBySuite = Map<string, QueriesInSuite>; +async function loadQueryListFromTextFile(filename: string): Promise<QueriesBySuite> { + const lines = (await fs.promises.readFile(filename, 'utf8')).split(/\r?\n/); + const allQueries = lines + .filter(l => l) + .map(l => { + const [doneStr, q] = l.split(/\s+/); + assert(doneStr === 'DONE' || doneStr === 'TODO', 'first column must be DONE or TODO'); + return { query: parseQuery(q), done: doneStr === 'DONE' } as const; + }); + + const queriesBySuite: QueriesBySuite = new Map(); + for (const q of allQueries) { + let suiteQueries = queriesBySuite.get(q.query.suite); + if (suiteQueries === undefined) { + suiteQueries = []; + queriesBySuite.set(q.query.suite, suiteQueries); + } + + suiteQueries.push(q); + } + + return queriesBySuite; +} + +function checkForOverlappingQueries(queries: QueriesInSuite): void { + for (let i1 = 0; i1 < queries.length; ++i1) { + for (let i2 = i1 + 1; i2 < queries.length; ++i2) { + const q1 = queries[i1].query; + const q2 = queries[i2].query; + if (compareQueries(q1, q2) !== Ordering.Unordered) { + console.log(` FYI, the following checklist items overlap:\n ${q1}\n ${q2}`); + } + } + } +} + +function checkForUnmatchedSubtreesAndDoneness( + tree: TestTree, + matchQueries: QueriesInSuite +): number { + let subtreeCount = 0; + const unmatchedSubtrees: TestQuery[] = []; + const overbroadMatches: [TestQuery, TestQuery][] = []; + const donenessMismatches: QueryInSuite[] = []; + const alwaysExpandThroughLevel = 1; // expand to, at minimum, every file. + for (const subtree of tree.iterateCollapsedNodes({ + includeIntermediateNodes: true, + includeEmptySubtrees: true, + alwaysExpandThroughLevel, + })) { + subtreeCount++; + const subtreeDone = !subtree.subtreeCounts?.nodesWithTODO; + + let subtreeMatched = false; + for (const q of matchQueries) { + const comparison = compareQueries(q.query, subtree.query); + if (comparison !== Ordering.Unordered) subtreeMatched = true; + if (comparison === Ordering.StrictSubset) continue; + if (comparison === Ordering.StrictSuperset) overbroadMatches.push([q.query, subtree.query]); + if (comparison === Ordering.Equal && q.done !== subtreeDone) donenessMismatches.push(q); + } + if (!subtreeMatched) unmatchedSubtrees.push(subtree.query); + } + + if (overbroadMatches.length) { + // (note, this doesn't show ALL multi-test queries - just ones that actually match any .spec.ts) + console.log(` FYI, the following checklist items were broader than one file:`); + for (const [q, collapsedSubtree] of overbroadMatches) { + console.log(` ${q} > ${collapsedSubtree}`); + } + } + + if (unmatchedSubtrees.length) { + throw new StacklessError(`Found unmatched tests:\n ${unmatchedSubtrees.join('\n ')}`); + } + + if (donenessMismatches.length) { + throw new StacklessError( + 'Found done/todo mismatches:\n ' + + donenessMismatches + .map(q => `marked ${q.done ? 'DONE, but is TODO' : 'TODO, but is DONE'}: ${q.query}`) + .join('\n ') + ); + } + + return subtreeCount; +} + +(async () => { + console.log('Loading queries...'); + const queriesBySuite = await loadQueryListFromTextFile(process.argv[2]); + console.log(' Found suites: ' + Array.from(queriesBySuite.keys()).join(' ')); + + const loader = new DefaultTestFileLoader(); + for (const [suite, queriesInSuite] of queriesBySuite.entries()) { + console.log(`Suite "${suite}":`); + console.log(` Checking overlaps between ${queriesInSuite.length} checklist items...`); + checkForOverlappingQueries(queriesInSuite); + const suiteQuery = new TestQueryMultiFile(suite, []); + console.log(` Loading tree ${suiteQuery}...`); + const tree = await loadTreeForQuery( + loader, + suiteQuery, + queriesInSuite.map(q => q.query) + ); + console.log(' Found no invalid queries in the checklist. Checking for unmatched tests...'); + const subtreeCount = checkForUnmatchedSubtreesAndDoneness(tree, queriesInSuite); + console.log(` No unmatched tests or done/todo mismatches among ${subtreeCount} subtrees!`); + } + console.log(`Checklist looks good!`); +})().catch(ex => { + console.log(ex.stack ?? ex.toString()); + process.exit(1); +}); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/crawl.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/crawl.ts new file mode 100644 index 0000000000..ae5cf41c2c --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/crawl.ts @@ -0,0 +1,102 @@ +// Node can look at the filesystem, but JS in the browser can't. +// This crawls the file tree under src/suites/${suite} to generate a (non-hierarchical) static +// listing file that can then be used in the browser to load the modules containing the tests. + +import * as fs from 'fs'; +import * as path from 'path'; + +import { SpecFile } from '../internal/file_loader.js'; +import { validQueryPart } from '../internal/query/validQueryPart.js'; +import { TestSuiteListingEntry, TestSuiteListing } from '../internal/test_suite_listing.js'; +import { assert, unreachable } from '../util/util.js'; + +const specFileSuffix = __filename.endsWith('.ts') ? '.spec.ts' : '.spec.js'; + +async function crawlFilesRecursively(dir: string): Promise<string[]> { + const subpathInfo = await Promise.all( + (await fs.promises.readdir(dir)).map(async d => { + const p = path.join(dir, d); + const stats = await fs.promises.stat(p); + return { + path: p, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + }) + ); + + const files = subpathInfo + .filter( + i => + i.isFile && + (i.path.endsWith(specFileSuffix) || + i.path.endsWith(`${path.sep}README.txt`) || + i.path === 'README.txt') + ) + .map(i => i.path); + + return files.concat( + await subpathInfo + .filter(i => i.isDirectory) + .map(i => crawlFilesRecursively(i.path)) + .reduce(async (a, b) => (await a).concat(await b), Promise.resolve([])) + ); +} + +export async function crawl( + suiteDir: string, + validate: boolean = true +): Promise<TestSuiteListingEntry[]> { + if (!fs.existsSync(suiteDir)) { + console.error(`Could not find ${suiteDir}`); + process.exit(1); + } + + // Crawl files and convert paths to be POSIX-style, relative to suiteDir. + const filesToEnumerate = (await crawlFilesRecursively(suiteDir)) + .map(f => path.relative(suiteDir, f).replace(/\\/g, '/')) + .sort(); + + const entries: TestSuiteListingEntry[] = []; + for (const file of filesToEnumerate) { + // |file| is the suite-relative file path. + if (file.endsWith(specFileSuffix)) { + const filepathWithoutExtension = file.substring(0, file.length - specFileSuffix.length); + + const suite = path.basename(suiteDir); + + if (validate) { + const filename = `../../${suite}/${filepathWithoutExtension}.spec.js`; + + assert(!process.env.STANDALONE_DEV_SERVER); + const mod = (await import(filename)) as SpecFile; + assert(mod.description !== undefined, 'Test spec file missing description: ' + filename); + assert(mod.g !== undefined, 'Test spec file missing TestGroup definition: ' + filename); + + mod.g.validate(); + } + + const pathSegments = filepathWithoutExtension.split('/'); + for (const p of pathSegments) { + assert(validQueryPart.test(p), `Invalid directory name ${p}; must match ${validQueryPart}`); + } + entries.push({ file: pathSegments }); + } else if (path.basename(file) === 'README.txt') { + const dirname = path.dirname(file); + const readme = fs.readFileSync(path.join(suiteDir, file), 'utf8').trim(); + + const pathSegments = dirname !== '.' ? dirname.split('/') : []; + entries.push({ file: pathSegments, readme }); + } else { + unreachable(`Matched an unrecognized filename ${file}`); + } + } + + return entries; +} + +export function makeListing(filename: string): Promise<TestSuiteListing> { + // Don't validate. This path is only used for the dev server and running tests with Node. + // Validation is done for listing generation and presubmit. + return crawl(path.dirname(filename), false); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/dev_server.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/dev_server.ts new file mode 100644 index 0000000000..2e0aca21dd --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/dev_server.ts @@ -0,0 +1,189 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import * as babel from '@babel/core'; +import * as chokidar from 'chokidar'; +import * as express from 'express'; +import * as morgan from 'morgan'; +import * as portfinder from 'portfinder'; +import * as serveIndex from 'serve-index'; + +import { makeListing } from './crawl.js'; + +// Make sure that makeListing doesn't cache imported spec files. See crawl(). +process.env.STANDALONE_DEV_SERVER = '1'; + +const srcDir = path.resolve(__dirname, '../../'); + +// Import the project's babel.config.js. We'll use the same config for the runtime compiler. +const babelConfig = { + ...require(path.resolve(srcDir, '../babel.config.js'))({ + cache: () => { + /* not used */ + }, + }), + sourceMaps: 'inline', +}; + +// Caches for the generated listing file and compiled TS sources to speed up reloads. +// Keyed by suite name +const listingCache = new Map<string, string>(); +// Keyed by the path to the .ts file, without src/ +const compileCache = new Map<string, string>(); + +console.log('Watching changes in', srcDir); +const watcher = chokidar.watch(srcDir, { + persistent: true, +}); + +/** + * Handler to dirty the compile cache for changed .ts files. + */ +function dirtyCompileCache(absPath: string, stats?: fs.Stats) { + const relPath = path.relative(srcDir, absPath); + if ((stats === undefined || stats.isFile()) && relPath.endsWith('.ts')) { + const tsUrl = relPath; + if (compileCache.has(tsUrl)) { + console.debug('Dirtying compile cache', tsUrl); + } + compileCache.delete(tsUrl); + } +} + +/** + * Handler to dirty the listing cache for: + * - Directory changes + * - .spec.ts changes + * - README.txt changes + * Also dirties the compile cache for changed files. + */ +function dirtyListingAndCompileCache(absPath: string, stats?: fs.Stats) { + const relPath = path.relative(srcDir, absPath); + + const segments = relPath.split(path.sep); + // The listing changes if the directories change, or if a .spec.ts file is added/removed. + const listingChange = + // A directory or a file with no extension that we can't stat. + // (stat doesn't work for deletions) + ((path.extname(relPath) === '' && (stats === undefined || !stats.isFile())) || + // A spec file + relPath.endsWith('.spec.ts') || + // A README.txt + path.basename(relPath, 'txt') === 'README') && + segments.length > 0; + if (listingChange) { + const suite = segments[0]; + if (listingCache.has(suite)) { + console.debug('Dirtying listing cache', suite); + } + listingCache.delete(suite); + } + + dirtyCompileCache(absPath, stats); +} + +watcher.on('add', dirtyListingAndCompileCache); +watcher.on('unlink', dirtyListingAndCompileCache); +watcher.on('addDir', dirtyListingAndCompileCache); +watcher.on('unlinkDir', dirtyListingAndCompileCache); +watcher.on('change', dirtyCompileCache); + +const app = express(); + +// Send Chrome Origin Trial tokens +app.use((req, res, next) => { + res.header('Origin-Trial', [ + // Token for http://localhost:8080 + 'AvyDIV+RJoYs8fn3W6kIrBhWw0te0klraoz04mw/nPb8VTus3w5HCdy+vXqsSzomIH745CT6B5j1naHgWqt/tw8AAABJeyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJmZWF0dXJlIjoiV2ViR1BVIiwiZXhwaXJ5IjoxNjYzNzE4Mzk5fQ==', + ]); + next(); +}); + +// Set up logging +app.use(morgan('dev')); + +// Serve the standalone runner directory +app.use('/standalone', express.static(path.resolve(srcDir, '../standalone'))); +// Add out-wpt/ build dir for convenience +app.use('/out-wpt', express.static(path.resolve(srcDir, '../out-wpt'))); +app.use('/docs/tsdoc', express.static(path.resolve(srcDir, '../docs/tsdoc'))); + +// Serve a suite's listing.js file by crawling the filesystem for all tests. +app.get('/out/:suite/listing.js', async (req, res, next) => { + const suite = req.params['suite']; + + if (listingCache.has(suite)) { + res.setHeader('Content-Type', 'application/javascript'); + res.send(listingCache.get(suite)); + return; + } + + try { + const listing = await makeListing(path.resolve(srcDir, suite, 'listing.ts')); + const result = `export const listing = ${JSON.stringify(listing, undefined, 2)}`; + + listingCache.set(suite, result); + res.setHeader('Content-Type', 'application/javascript'); + res.send(result); + } catch (err) { + next(err); + } +}); + +// Serve all other .js files by fetching the source .ts file and compiling it. +app.get('/out/**/*.js', async (req, res, next) => { + const jsUrl = path.relative('/out', req.url); + const tsUrl = jsUrl.replace(/\.js$/, '.ts'); + if (compileCache.has(tsUrl)) { + res.setHeader('Content-Type', 'application/javascript'); + res.send(compileCache.get(tsUrl)); + return; + } + + let absPath = path.join(srcDir, tsUrl); + if (!fs.existsSync(absPath)) { + // The .ts file doesn't exist. Try .js file in case this is a .js/.d.ts pair. + absPath = path.join(srcDir, jsUrl); + } + + try { + const result = await babel.transformFileAsync(absPath, babelConfig); + if (result && result.code) { + compileCache.set(tsUrl, result.code); + + res.setHeader('Content-Type', 'application/javascript'); + res.send(result.code); + } else { + throw new Error(`Failed compile ${tsUrl}.`); + } + } catch (err) { + next(err); + } +}); + +const host = '0.0.0.0'; +const port = 8080; +// Find an available port, starting at 8080. +portfinder.getPort({ host, port }, (err, port) => { + if (err) { + throw err; + } + watcher.on('ready', () => { + // Listen on the available port. + app.listen(port, host, () => { + console.log('Standalone test runner running at:'); + for (const iface of Object.values(os.networkInterfaces())) { + for (const details of iface || []) { + if (details.family === 'IPv4') { + console.log(` http://${details.address}:${port}/standalone/`); + } + } + } + }); + }); +}); + +// Serve everything else (not .js) as static, and directories as directory listings. +app.use('/out', serveIndex(path.resolve(srcDir, '../src'))); +app.use('/out', express.static(path.resolve(srcDir, '../src'))); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/gen_cache.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_cache.ts new file mode 100644 index 0000000000..e7e6d8514f --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_cache.ts @@ -0,0 +1,144 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as process from 'process'; + +import { Cacheable, dataCache, setIsBuildingDataCache } from '../framework/data_cache.js'; + +function usage(rc: number): void { + console.error(`Usage: tools/gen_cache [options] [OUT_DIR] [SUITE_DIRS...] + +For each suite in SUITE_DIRS, pre-compute data that is expensive to generate +at runtime and store it under OUT_DIR. If the data file is found then the +DataCache will load this instead of building the expensive data at CTS runtime. + +Options: + --help Print this message and exit. + --list Print the list of output files without writing them. +`); + process.exit(rc); +} + +let mode: 'emit' | 'list' = 'emit'; + +const nonFlagsArgs: string[] = []; +for (const a of process.argv) { + if (a.startsWith('-')) { + if (a === '--list') { + mode = 'list'; + } else if (a === '--help') { + usage(0); + } else { + console.log('unrecognized flag: ', a); + usage(1); + } + } else { + nonFlagsArgs.push(a); + } +} + +if (nonFlagsArgs.length < 4) { + usage(0); +} + +const outRootDir = nonFlagsArgs[2]; + +dataCache.setStore({ + load: (path: string) => { + return new Promise<string>((resolve, reject) => { + fs.readFile(`data/${path}`, 'utf8', (err, data) => { + if (err !== null) { + reject(err.message); + } else { + resolve(data); + } + }); + }); + }, +}); +setIsBuildingDataCache(); + +void (async () => { + for (const suiteDir of nonFlagsArgs.slice(3)) { + await build(suiteDir); + } +})(); + +const specFileSuffix = __filename.endsWith('.ts') ? '.spec.ts' : '.spec.js'; + +async function crawlFilesRecursively(dir: string): Promise<string[]> { + const subpathInfo = await Promise.all( + (await fs.promises.readdir(dir)).map(async d => { + const p = path.join(dir, d); + const stats = await fs.promises.stat(p); + return { + path: p, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + }) + ); + + const files = subpathInfo + .filter(i => i.isFile && i.path.endsWith(specFileSuffix)) + .map(i => i.path); + + return files.concat( + await subpathInfo + .filter(i => i.isDirectory) + .map(i => crawlFilesRecursively(i.path)) + .reduce(async (a, b) => (await a).concat(await b), Promise.resolve([])) + ); +} + +async function build(suiteDir: string) { + if (!fs.existsSync(suiteDir)) { + console.error(`Could not find ${suiteDir}`); + process.exit(1); + } + + // Crawl files and convert paths to be POSIX-style, relative to suiteDir. + const filesToEnumerate = (await crawlFilesRecursively(suiteDir)).sort(); + + const cacheablePathToTS = new Map<string, string>(); + + for (const file of filesToEnumerate) { + if (file.endsWith(specFileSuffix)) { + const pathWithoutExtension = file.substring(0, file.length - specFileSuffix.length); + const mod = await import(`../../../${pathWithoutExtension}.spec.js`); + if (mod.d?.serialize !== undefined) { + const cacheable = mod.d as Cacheable<unknown>; + + { + // Check for collisions + const existing = cacheablePathToTS.get(cacheable.path); + if (existing !== undefined) { + console.error( + `error: Cacheable '${cacheable.path}' is emitted by both: + '${existing}' +and + '${file}'` + ); + process.exit(1); + } + cacheablePathToTS.set(cacheable.path, file); + } + + const outPath = `${outRootDir}/data/${cacheable.path}`; + + switch (mode) { + case 'emit': { + const data = await cacheable.build(); + const serialized = cacheable.serialize(data); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized); + break; + } + case 'list': { + console.log(outPath); + break; + } + } + } + } + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/gen_listings.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_listings.ts new file mode 100644 index 0000000000..7b7809c920 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_listings.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as process from 'process'; + +import { crawl } from './crawl.js'; + +function usage(rc: number): void { + console.error(`Usage: tools/gen_listings [options] [OUT_DIR] [SUITE_DIRS...] + +For each suite in SUITE_DIRS, generate listings and write each listing.js +into OUT_DIR/{suite}/listing.js. Example: + tools/gen_listings out/ src/unittests/ src/webgpu/ + +Options: + --help Print this message and exit. + --no-validate Whether to validate test modules while crawling. +`); + process.exit(rc); +} + +const argv = process.argv; +if (argv.indexOf('--help') !== -1) { + usage(0); +} + +let validate = true; +{ + const i = argv.indexOf('--no-validate'); + if (i !== -1) { + validate = false; + argv.splice(i, 1); + } +} + +if (argv.length < 4) { + usage(0); +} + +const myself = 'src/common/tools/gen_listings.ts'; + +const outDir = argv[2]; + +void (async () => { + for (const suiteDir of argv.slice(3)) { + const listing = await crawl(suiteDir, validate); + + const suite = path.basename(suiteDir); + const outFile = path.normalize(path.join(outDir, `${suite}/listing.js`)); + fs.mkdirSync(path.join(outDir, suite), { recursive: true }); + fs.writeFileSync( + outFile, + `\ +// AUTO-GENERATED - DO NOT EDIT. See ${myself}. + +export const listing = ${JSON.stringify(listing, undefined, 2)}; +` + ); + try { + fs.unlinkSync(outFile + '.map'); + } catch (ex) { + // ignore if file didn't exist + } + } +})(); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/gen_wpt_cts_html.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_wpt_cts_html.ts new file mode 100644 index 0000000000..28e8fb4437 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/gen_wpt_cts_html.ts @@ -0,0 +1,122 @@ +import { promises as fs } from 'fs'; + +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { TestQueryMultiFile } from '../internal/query/query.js'; +import { assert } from '../util/util.js'; + +function printUsageAndExit(rc: number): void { + console.error(`\ +Usage: + tools/gen_wpt_cts_html OUTPUT_FILE TEMPLATE_FILE [ARGUMENTS_PREFIXES_FILE EXPECTATIONS_FILE EXPECTATIONS_PREFIX [SUITE]] + tools/gen_wpt_cts_html out-wpt/cts.https.html templates/cts.https.html + tools/gen_wpt_cts_html my/path/to/cts.https.html templates/cts.https.html arguments.txt myexpectations.txt 'path/to/cts.https.html' cts + +where arguments.txt is a file containing a list of arguments prefixes to both generate and expect +in the expectations. The entire variant list generation runs *once per prefix*, so this +multiplies the size of the variant list. + + ?worker=0&q= + ?worker=1&q= + +and myexpectations.txt is a file containing a list of WPT paths to suppress, e.g.: + + path/to/cts.https.html?worker=0&q=webgpu:a/foo:bar={"x":1} + path/to/cts.https.html?worker=1&q=webgpu:a/foo:bar={"x":1} + + path/to/cts.https.html?worker=1&q=webgpu:a/foo:bar={"x":3} +`); + process.exit(rc); +} + +if (process.argv.length !== 4 && process.argv.length !== 7 && process.argv.length !== 8) { + printUsageAndExit(0); +} + +const [ + , + , + outFile, + templateFile, + argsPrefixesFile, + expectationsFile, + expectationsPrefix, + suite = 'webgpu', +] = process.argv; + +(async () => { + let argsPrefixes = ['']; + let expectationLines = new Set<string>(); + + if (process.argv.length >= 7) { + // Prefixes sorted from longest to shortest + const argsPrefixesFromFile = (await fs.readFile(argsPrefixesFile, 'utf8')) + .split(/\r?\n/) + .filter(a => a.length) + .sort((a, b) => b.length - a.length); + if (argsPrefixesFromFile.length) argsPrefixes = argsPrefixesFromFile; + expectationLines = new Set( + (await fs.readFile(expectationsFile, 'utf8')).split(/\r?\n/).filter(l => l.length) + ); + } + + const expectations: Map<string, string[]> = new Map(); + for (const prefix of argsPrefixes) { + expectations.set(prefix, []); + } + + expLoop: for (const exp of expectationLines) { + // Take each expectation for the longest prefix it matches. + for (const argsPrefix of argsPrefixes) { + const prefix = expectationsPrefix + argsPrefix; + if (exp.startsWith(prefix)) { + expectations.get(argsPrefix)!.push(exp.substring(prefix.length)); + continue expLoop; + } + } + console.log('note: ignored expectation: ' + exp); + } + + const loader = new DefaultTestFileLoader(); + const lines: Array<string | undefined> = []; + for (const prefix of argsPrefixes) { + const rootQuery = new TestQueryMultiFile(suite, []); + const tree = await loader.loadTree(rootQuery, expectations.get(prefix)); + + lines.push(undefined); // output blank line between prefixes + const alwaysExpandThroughLevel = 2; // expand to, at minimum, every test. + for (const { query } of tree.iterateCollapsedNodes({ alwaysExpandThroughLevel })) { + const urlQueryString = prefix + query.toString(); // "?worker=0&q=..." + // Check for a safe-ish path length limit. Filename must be <= 255, and on Windows the whole + // path must be <= 259. Leave room for e.g.: + // 'c:\b\s\w\xxxxxxxx\layout-test-results\external\wpt\webgpu\cts_worker=0_q=...-actual.txt' + assert( + urlQueryString.length < 185, + 'Generated test variant would produce too-long -actual.txt filename. \ +Try broadening suppressions to avoid long test variant names. ' + + urlQueryString + ); + lines.push(urlQueryString); + } + } + await generateFile(lines); +})().catch(ex => { + console.log(ex.stack ?? ex.toString()); + process.exit(1); +}); + +async function generateFile(lines: Array<string | undefined>): Promise<void> { + let result = ''; + result += '<!-- AUTO-GENERATED - DO NOT EDIT. See WebGPU CTS: tools/gen_wpt_cts_html. -->\n'; + + result += await fs.readFile(templateFile, 'utf8'); + + for (const line of lines) { + if (line === undefined) { + result += '\n'; + } else { + result += `<meta name=variant content='${line}'>\n`; + } + } + + await fs.writeFile(outFile, result); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/image_utils.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/image_utils.ts new file mode 100644 index 0000000000..3c51cfdce3 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/image_utils.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; + +import { Page } from 'playwright-core'; +import { PNG } from 'pngjs'; +import { screenshot, WindowInfo } from 'screenshot-ftw'; + +// eslint-disable-next-line ban/ban +const waitMS = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function readPng(filename: string) { + const data = fs.readFileSync(filename); + return PNG.sync.read(data); +} + +export function writePng(filename: string, width: number, height: number, data: Buffer) { + const png = new PNG({ colorType: 6, width, height }); + for (let i = 0; i < data.byteLength; ++i) { + png.data[i] = data[i]; + } + const buffer = PNG.sync.write(png); + fs.writeFileSync(filename, buffer); +} + +export class ScreenshotManager { + window?: WindowInfo; + + async init(page: Page) { + // set the title to some random number so we can find the window by title + const title: string = await page.evaluate(() => { + const title = `t-${Math.random()}`; + document.title = title; + return title; + }); + + // wait for the window to show up + let window; + for (let i = 0; !window && i < 100; ++i) { + await waitMS(50); + const windows = await screenshot.getWindows(); + window = windows.find(window => window.title.includes(title)); + } + if (!window) { + throw Error(`could not find window: ${title}`); + } + this.window = window; + } + + async takeScreenshot(page: Page, screenshotName: string) { + // await page.screenshot({ path: screenshotName }); + + // we need to set the url and title since the screenshot will include the chrome + await page.evaluate(async () => { + document.title = 'screenshot'; + window.history.replaceState({}, '', '/screenshot'); + }); + await screenshot.captureWindowById(screenshotName, this.window!.id); + } +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/presubmit.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/presubmit.ts new file mode 100644 index 0000000000..27505e759e --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/presubmit.ts @@ -0,0 +1,19 @@ +import { DefaultTestFileLoader } from '../internal/file_loader.js'; +import { parseQuery } from '../internal/query/parseQuery.js'; +import { assert } from '../util/util.js'; + +void (async () => { + for (const suite of ['unittests', 'webgpu']) { + const loader = new DefaultTestFileLoader(); + const filterQuery = parseQuery(`${suite}:*`); + const testcases = await loader.loadCases(filterQuery); + for (const testcase of testcases) { + const name = testcase.query.toString(); + const maxLength = 375; + assert( + name.length <= maxLength, + `Testcase ${name} is too long. Max length is ${maxLength} characters. Please shorten names or reduce parameters.` + ); + } + } +})(); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/run_wpt_ref_tests.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/run_wpt_ref_tests.ts new file mode 100644 index 0000000000..42ff60001c --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/run_wpt_ref_tests.ts @@ -0,0 +1,446 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { chromium, firefox, webkit, Page, Browser } from 'playwright-core'; + +import { ScreenshotManager, readPng, writePng } from './image_utils.js'; + +declare function wptRefTestPageReady(): boolean; +declare function wptRefTestGetTimeout(): boolean; + +const verbose = !!process.env.VERBOSE; +const kRefTestsBaseURL = 'http://localhost:8080/out/webgpu/web_platform/reftests'; +const kRefTestsPath = 'src/webgpu/web_platform/reftests'; +const kScreenshotPath = 'out-wpt-reftest-screenshots'; + +// note: technically we should use an HTML parser to find this to deal with whitespace +// attribute order, quotes, entities, etc but since we control the test source we can just +// make sure they match +const kRefLinkRE = /<link\s+rel="match"\s+href="(.*?)"/; +const kRefWaitClassRE = /class="reftest-wait"/; +const kFuzzy = /<meta\s+name="?fuzzy"?\s+content="(.*?)">/; + +function printUsage() { + console.log(` +run_wpt_ref_tests path-to-browser-executable [ref-test-name] + +where ref-test-name is just a simple check for the test including the given string. +If not passed all ref tests are run + +MacOS Chrome Example: + node tools/run_wpt_ref_tests /Applications/Google\\ Chrome\\ Canary.app/Contents/MacOS/Google\\ Chrome\\ Canary + +`); +} + +// Get all of filenames that end with '.html' +function getRefTestNames(refTestPath: string) { + return fs.readdirSync(refTestPath).filter(name => name.endsWith('.html')); +} + +// Given a regex with one capture, return it or the empty string if no match. +function getRegexMatchCapture(re: RegExp, content: string) { + const m = re.exec(content); + return m ? m[1] : ''; +} + +type FileInfo = { + content: string; + refLink: string; + refWait: boolean; + fuzzy: string; +}; + +function readHTMLFile(filename: string): FileInfo { + const content = fs.readFileSync(filename, { encoding: 'utf8' }); + return { + content, + refLink: getRegexMatchCapture(kRefLinkRE, content), + refWait: kRefWaitClassRE.test(content), + fuzzy: getRegexMatchCapture(kFuzzy, content), + }; +} + +/** + * This is workaround for a bug in Chrome. The bug is when in emulation mode + * Chrome lets you set a devicePixelRatio but Chrome still renders in the + * actual devicePixelRatio, at least on MacOS. + * So, we compute the ratio and then use that. + */ +async function getComputedDevicePixelRatio(browser: Browser): Promise<number> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('data:text/html,<html></html>'); + await page.waitForLoadState('networkidle'); + const devicePixelRatio = await page.evaluate(() => { + let resolve: (v: number) => void; + const promise = new Promise(_resolve => (resolve = _resolve)); + const observer = new ResizeObserver(entries => { + const devicePixelWidth = entries[0].devicePixelContentBoxSize[0].inlineSize; + const clientWidth = entries[0].target.clientWidth; + const devicePixelRatio = devicePixelWidth / clientWidth; + resolve(devicePixelRatio); + }); + observer.observe(document.documentElement); + return promise; + }); + await page.close(); + await context.close(); + return devicePixelRatio as number; +} + +// Note: If possible, rather then start adding command line options to this tool, +// see if you can just make it work based off the path. +async function getBrowserInterface(executablePath: string) { + const lc = executablePath.toLowerCase(); + if (lc.includes('chrom')) { + const browser = await chromium.launch({ + executablePath, + headless: false, + args: ['--enable-unsafe-webgpu'], + }); + const devicePixelRatio = await getComputedDevicePixelRatio(browser); + const context = await browser.newContext({ + deviceScaleFactor: devicePixelRatio, + }); + return { browser, context }; + } else if (lc.includes('firefox')) { + const browser = await firefox.launch({ + executablePath, + headless: false, + }); + const context = await browser.newContext(); + return { browser, context }; + } else if (lc.includes('safari') || lc.includes('webkit')) { + const browser = await webkit.launch({ + executablePath, + headless: false, + }); + const context = await browser.newContext(); + return { browser, context }; + } else { + throw new Error(`could not guess browser from executable path: ${executablePath}`); + } +} + +// Parses a fuzzy spec as defined here +// https://web-platform-tests.org/writing-tests/reftests.html#fuzzy-matching +// Note: This is not robust but the tests will eventually be run in the real wpt. +function parseFuzzy(fuzzy: string) { + if (!fuzzy) { + return { maxDifference: [0, 0], totalPixels: [0, 0] }; + } else { + const parts = fuzzy.split(';'); + if (parts.length !== 2) { + throw Error(`unhandled fuzzy format: ${fuzzy}`); + } + const ranges = parts.map(part => { + const range = part + .replace(/[a-zA-Z=]/g, '') + .split('-') + .map(v => parseInt(v)); + return range.length === 1 ? [0, range[0]] : range; + }); + return { + maxDifference: ranges[0], + totalPixels: ranges[1], + }; + } +} + +// Compares two images using the algorithm described in the web platform tests +// https://web-platform-tests.org/writing-tests/reftests.html#fuzzy-matching +// If they are different will write out a diff mask. +async function compareImages( + filename1: string, + filename2: string, + fuzzy: string, + diffName: string, + startingRow: number = 0 +) { + const img1 = readPng(filename1); + const img2 = readPng(filename2); + const { width, height } = img1; + if (img2.width !== width || img2.height !== height) { + console.error('images are not the same size:', filename1, filename2); + return; + } + + const { maxDifference, totalPixels } = parseFuzzy(fuzzy); + + const diffData = Buffer.alloc(width * height * 4); + const diffPixels = new Uint32Array(diffData.buffer); + const kRed = 0xff0000ff; + const kWhite = 0xffffffff; + const kYellow = 0xff00ffff; + + let numPixelsDifferent = 0; + let anyPixelsOutOfRange = false; + for (let y = startingRow; y < height; ++y) { + for (let x = 0; x < width; ++x) { + const offset = y * width + x; + let isDifferent = false; + let outOfRange = false; + for (let c = 0; c < 4 && !outOfRange; ++c) { + const off = offset * 4 + c; + const v0 = img1.data[off]; + const v1 = img2.data[off]; + const channelDiff = Math.abs(v0 - v1); + outOfRange ||= channelDiff < maxDifference[0] || channelDiff > maxDifference[1]; + isDifferent ||= channelDiff > 0; + } + numPixelsDifferent += isDifferent ? 1 : 0; + anyPixelsOutOfRange ||= outOfRange; + diffPixels[offset] = outOfRange ? kRed : isDifferent ? kYellow : kWhite; + } + } + + const pass = + !anyPixelsOutOfRange && + numPixelsDifferent >= totalPixels[0] && + numPixelsDifferent <= totalPixels[1]; + if (!pass) { + writePng(diffName, width, height, diffData); + console.error( + `FAIL: too many differences in: ${filename1} vs ${filename2} + ${numPixelsDifferent} differences, expected: ${totalPixels[0]}-${totalPixels[1]} with range: ${maxDifference[0]}-${maxDifference[1]} + wrote difference to: ${diffName}; + ` + ); + } else { + console.log(`PASS`); + } + return pass; +} + +function exists(filename: string) { + try { + fs.accessSync(filename); + return true; + } catch (e) { + return false; + } +} + +async function waitForPageRender(page: Page) { + await page.evaluate(() => { + return new Promise(resolve => requestAnimationFrame(resolve)); + }); +} + +// returns true if the page timed out. +async function runPage(page: Page, url: string, refWait: boolean) { + console.log(' loading:', url); + // we need to load about:blank to force the browser to re-render + // else the previous page may still be visible if the page we are loading fails + await page.goto('about:blank'); + await page.waitForLoadState('domcontentloaded'); + await waitForPageRender(page); + + await page.goto(url); + await page.waitForLoadState('domcontentloaded'); + await waitForPageRender(page); + + if (refWait) { + await page.waitForFunction(() => wptRefTestPageReady()); + const timeout = await page.evaluate(() => wptRefTestGetTimeout()); + if (timeout) { + return true; + } + } + return false; +} + +async function main() { + const args = process.argv.slice(2); + if (args.length < 1 || args.length > 2) { + printUsage(); + return; + } + + const [executablePath, refTestName] = args; + + if (!exists(executablePath)) { + console.error(executablePath, 'does not exist'); + return; + } + + const testNames = getRefTestNames(kRefTestsPath).filter(name => + refTestName ? name.includes(refTestName) : true + ); + + if (!exists(kScreenshotPath)) { + fs.mkdirSync(kScreenshotPath, { recursive: true }); + } + + if (testNames.length === 0) { + console.error(`no tests include "${refTestName}"`); + return; + } + + const { browser, context } = await getBrowserInterface(executablePath); + const page = await context.newPage(); + + const screenshotManager = new ScreenshotManager(); + await screenshotManager.init(page); + + if (verbose) { + page.on('console', async msg => { + const { url, lineNumber, columnNumber } = msg.location(); + const values = await Promise.all(msg.args().map(a => a.jsonValue())); + console.log(`${url}:${lineNumber}:${columnNumber}:`, ...values); + }); + } + + await page.addInitScript({ + content: ` + (() => { + let timeout = false; + setTimeout(() => timeout = true, 5000); + + window.wptRefTestPageReady = function() { + return timeout || !document.documentElement.classList.contains('reftest-wait'); + }; + + window.wptRefTestGetTimeout = function() { + return timeout; + }; + })(); + `, + }); + + type Result = { + status: string; + testName: string; + refName: string; + testScreenshotName: string; + refScreenshotName: string; + diffName: string; + }; + const results: Result[] = []; + const addResult = ( + status: string, + testName: string, + refName: string, + testScreenshotName: string = '', + refScreenshotName: string = '', + diffName: string = '' + ) => { + results.push({ status, testName, refName, testScreenshotName, refScreenshotName, diffName }); + }; + + for (const testName of testNames) { + console.log('processing:', testName); + const { refLink, refWait, fuzzy } = readHTMLFile(path.join(kRefTestsPath, testName)); + if (!refLink) { + throw new Error(`could not find ref link in: ${testName}`); + } + const testURL = `${kRefTestsBaseURL}/${testName}`; + const refURL = `${kRefTestsBaseURL}/${refLink}`; + + // Technically this is not correct but it fits the existing tests. + // It assumes refLink is relative to the refTestsPath but it's actually + // supposed to be relative to the test. It might also be an absolute + // path. Neither of those cases exist at the time of writing this. + const refFileInfo = readHTMLFile(path.join(kRefTestsPath, refLink)); + const testScreenshotName = path.join(kScreenshotPath, `${testName}-actual.png`); + const refScreenshotName = path.join(kScreenshotPath, `${testName}-expected.png`); + const diffName = path.join(kScreenshotPath, `${testName}-diff.png`); + + const timeoutTest = await runPage(page, testURL, refWait); + if (timeoutTest) { + addResult('TIMEOUT', testName, refLink); + continue; + } + await screenshotManager.takeScreenshot(page, testScreenshotName); + + const timeoutRef = await runPage(page, refURL, refFileInfo.refWait); + if (timeoutRef) { + addResult('TIMEOUT', testName, refLink); + continue; + } + await screenshotManager.takeScreenshot(page, refScreenshotName); + + const pass = await compareImages(testScreenshotName, refScreenshotName, fuzzy, diffName); + addResult( + pass ? 'PASS' : 'FAILURE', + testName, + refLink, + testScreenshotName, + refScreenshotName, + diffName + ); + } + + console.log( + `----results----\n${results + .map(({ status, testName }) => `[ ${status.padEnd(7)} ] ${testName}`) + .join('\n')}` + ); + + const imgLink = (filename: string, title: string) => { + const name = path.basename(filename); + return ` + <div class="screenshot"> + ${title} + <a href="${name}" title="${name}"> + <img src="${name}" width="256"/> + </a> + </div>`; + }; + + const indexName = path.join(kScreenshotPath, 'index.html'); + fs.writeFileSync( + indexName, + `<!DOCTYPE html> +<html> + <head> + <style> + .screenshot { + display: inline-block; + background: #CCC; + margin-right: 5px; + padding: 5px; + } + .screenshot a { + display: block; + } + .screenshot + </style> + </head> + <body> + ${results + .map(({ status, testName, refName, testScreenshotName, refScreenshotName, diffName }) => { + return ` + <div> + <div>[ ${status} ]: ${testName} ref: ${refName}</div> + ${ + status === 'FAILURE' + ? `${imgLink(testScreenshotName, 'actual')} + ${imgLink(refScreenshotName, 'ref')} + ${imgLink(diffName, 'diff')}` + : `` + } + </div> + <hr> + `; + }) + .join('\n')} + </body> +</html> + ` + ); + + // the file:// with an absolute path makes it clickable in some terminals + console.log(`\nsee: file://${path.resolve(indexName)}\n`); + + await page.close(); + await context.close(); + // I have no idea why it's taking ~30 seconds for playwright to close. + console.log('-- [ done: waiting for browser to close ] --'); + await browser.close(); +} + +main().catch(e => { + throw e; +}); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/setup-ts-in-node.js b/dom/webgpu/tests/cts/checkout/src/common/tools/setup-ts-in-node.js new file mode 100644 index 0000000000..89e91e8c9d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/setup-ts-in-node.js @@ -0,0 +1,51 @@ +const path = require('path'); + +// Automatically transpile .ts imports +require('ts-node').register({ + // Specify the project file so ts-node doesn't try to find it itself based on the CWD. + project: path.resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'commonjs', + }, + transpileOnly: true, +}); +const Module = require('module'); + +// Redirect imports of .js files to .ts files +const resolveFilename = Module._resolveFilename; +Module._resolveFilename = (request, parentModule, isMain) => { + do { + if (request.startsWith('.') && parentModule.filename.endsWith('.ts')) { + // Required for browser (because it needs the actual correct file path and + // can't do any kind of file resolution). + if (request.endsWith('/index.js')) { + throw new Error( + "Avoid the name `index.js`; we don't have Node-style path resolution: " + request + ); + } + + // Import of Node addon modules are valid and should pass through. + if (request.endsWith('.node')) { + break; + } + + if (!request.endsWith('.js')) { + throw new Error('All relative imports must end in .js: ' + request); + } + + try { + const tsRequest = request.substring(0, request.length - '.js'.length) + '.ts'; + return resolveFilename.call(this, tsRequest, parentModule, isMain); + } catch (ex) { + // If the .ts file doesn't exist, try .js instead. + break; + } + } + } while (0); + + return resolveFilename.call(this, request, parentModule, isMain); +}; + +process.on('unhandledRejection', ex => { + throw ex; +}); diff --git a/dom/webgpu/tests/cts/checkout/src/common/tools/version.ts b/dom/webgpu/tests/cts/checkout/src/common/tools/version.ts new file mode 100644 index 0000000000..2b51700b12 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/tools/version.ts @@ -0,0 +1,4 @@ +export const version = require('child_process') + .execSync('git describe --always --abbrev=0 --dirty') + .toString() + .trim(); diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/collect_garbage.ts b/dom/webgpu/tests/cts/checkout/src/common/util/collect_garbage.ts new file mode 100644 index 0000000000..670028d41c --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/collect_garbage.ts @@ -0,0 +1,58 @@ +import { resolveOnTimeout } from './util.js'; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +declare const Components: any; + +/** + * Attempts to trigger JavaScript garbage collection, either using explicit methods if exposed + * (may be available in testing environments with special browser runtime flags set), or using + * some weird tricks to incur GC pressure. Adopted from the WebGL CTS. + */ +export async function attemptGarbageCollection(): Promise<void> { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const w: any = globalThis; + if (w.GCController) { + w.GCController.collect(); + return; + } + + if (w.opera && w.opera.collect) { + w.opera.collect(); + return; + } + + try { + w.QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIDOMWindowUtils) + .garbageCollect(); + return; + } catch (e) { + // ignore any failure + } + + if (w.gc) { + w.gc(); + return; + } + + if (w.CollectGarbage) { + w.CollectGarbage(); + return; + } + + let i: number; + function gcRec(n: number): void { + if (n < 1) return; + /* eslint-disable @typescript-eslint/restrict-plus-operands */ + let temp: object | string = { i: 'ab' + i + i / 100000 }; + /* eslint-disable @typescript-eslint/restrict-plus-operands */ + temp = temp + 'foo'; + temp; // dummy use of unused variable + gcRec(n - 1); + } + for (i = 0; i < 1000; i++) { + gcRec(10); + } + + return resolveOnTimeout(35); // Let the event loop run a few frames in case it helps. +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/colors.ts b/dom/webgpu/tests/cts/checkout/src/common/util/colors.ts new file mode 100644 index 0000000000..709d159320 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/colors.ts @@ -0,0 +1,127 @@ +/** + * The interface used for formatting strings to contain color metadata. + * + * Use the interface properties to construct a style, then use the + * `(s: string): string` function to format the provided string with the given + * style. + */ +export interface Colors { + // Are colors enabled? + enabled: boolean; + + // Returns the string formatted to contain the specified color or style. + (s: string): string; + + // modifiers + reset: Colors; + bold: Colors; + dim: Colors; + italic: Colors; + underline: Colors; + inverse: Colors; + hidden: Colors; + strikethrough: Colors; + + // colors + black: Colors; + red: Colors; + green: Colors; + yellow: Colors; + blue: Colors; + magenta: Colors; + cyan: Colors; + white: Colors; + gray: Colors; + grey: Colors; + + // bright colors + blackBright: Colors; + redBright: Colors; + greenBright: Colors; + yellowBright: Colors; + blueBright: Colors; + magentaBright: Colors; + cyanBright: Colors; + whiteBright: Colors; + + // background colors + bgBlack: Colors; + bgRed: Colors; + bgGreen: Colors; + bgYellow: Colors; + bgBlue: Colors; + bgMagenta: Colors; + bgCyan: Colors; + bgWhite: Colors; + + // bright background colors + bgBlackBright: Colors; + bgRedBright: Colors; + bgGreenBright: Colors; + bgYellowBright: Colors; + bgBlueBright: Colors; + bgMagentaBright: Colors; + bgCyanBright: Colors; + bgWhiteBright: Colors; +} + +/** + * The interface used for formatting strings with color metadata. + * + * Currently Colors will use the 'ansi-colors' module if it can be loaded. + * If it cannot be loaded, then the Colors implementation is a straight pass-through. + * + * Colors may also be a no-op if the current environment does not support colors. + */ +export let Colors: Colors; + +try { + /* eslint-disable-next-line node/no-unpublished-require */ + Colors = require('ansi-colors') as Colors; +} catch { + const passthrough = ((s: string) => s) as Colors; + passthrough.enabled = false; + passthrough.reset = passthrough; + passthrough.bold = passthrough; + passthrough.dim = passthrough; + passthrough.italic = passthrough; + passthrough.underline = passthrough; + passthrough.inverse = passthrough; + passthrough.hidden = passthrough; + passthrough.strikethrough = passthrough; + passthrough.black = passthrough; + passthrough.red = passthrough; + passthrough.green = passthrough; + passthrough.yellow = passthrough; + passthrough.blue = passthrough; + passthrough.magenta = passthrough; + passthrough.cyan = passthrough; + passthrough.white = passthrough; + passthrough.gray = passthrough; + passthrough.grey = passthrough; + passthrough.blackBright = passthrough; + passthrough.redBright = passthrough; + passthrough.greenBright = passthrough; + passthrough.yellowBright = passthrough; + passthrough.blueBright = passthrough; + passthrough.magentaBright = passthrough; + passthrough.cyanBright = passthrough; + passthrough.whiteBright = passthrough; + passthrough.bgBlack = passthrough; + passthrough.bgRed = passthrough; + passthrough.bgGreen = passthrough; + passthrough.bgYellow = passthrough; + passthrough.bgBlue = passthrough; + passthrough.bgMagenta = passthrough; + passthrough.bgCyan = passthrough; + passthrough.bgWhite = passthrough; + passthrough.bgBlackBright = passthrough; + passthrough.bgRedBright = passthrough; + passthrough.bgGreenBright = passthrough; + passthrough.bgYellowBright = passthrough; + passthrough.bgBlueBright = passthrough; + passthrough.bgMagentaBright = passthrough; + passthrough.bgCyanBright = passthrough; + passthrough.bgWhiteBright = passthrough; + Colors = passthrough; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/data_tables.ts b/dom/webgpu/tests/cts/checkout/src/common/util/data_tables.ts new file mode 100644 index 0000000000..7f1be2f701 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/data_tables.ts @@ -0,0 +1,39 @@ +import { ResolveType, ZipKeysWithValues } from './types.js'; + +export type valueof<K> = K[keyof K]; + +export function keysOf<T extends string>(obj: { [k in T]: unknown }): readonly T[] { + return (Object.keys(obj) as unknown[]) as T[]; +} + +export function numericKeysOf<T>(obj: object): readonly T[] { + return (Object.keys(obj).map(n => Number(n)) as unknown[]) as T[]; +} + +/** + * Creates an info lookup object from a more nicely-formatted table. See below for examples. + * + * Note: Using `as const` on the arguments to this function is necessary to infer the correct type. + */ +export function makeTable< + Members extends readonly string[], + Defaults extends readonly unknown[], + Table extends { readonly [k: string]: readonly unknown[] } +>( + members: Members, + defaults: Defaults, + table: Table +): { + readonly [k in keyof Table]: ResolveType<ZipKeysWithValues<Members, Table[k], Defaults>>; +} { + const result: { [k: string]: { [m: string]: unknown } } = {}; + for (const [k, v] of Object.entries<readonly unknown[]>(table)) { + const item: { [m: string]: unknown } = {}; + for (let i = 0; i < members.length; ++i) { + item[members[i]] = v[i] ?? defaults[i]; + } + result[k] = item; + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return result as any; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/navigator_gpu.ts b/dom/webgpu/tests/cts/checkout/src/common/util/navigator_gpu.ts new file mode 100644 index 0000000000..47cb1a4701 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/navigator_gpu.ts @@ -0,0 +1,74 @@ +/// <reference types="@webgpu/types" /> + +import { assert } from './util.js'; + +/** + * Finds and returns the `navigator.gpu` object (or equivalent, for non-browser implementations). + * Throws an exception if not found. + */ +function defaultGPUProvider(): GPU { + assert( + typeof navigator !== 'undefined' && navigator.gpu !== undefined, + 'No WebGPU implementation found' + ); + return navigator.gpu; +} + +/** + * GPUProvider is a function that creates and returns a new GPU instance. + * May throw an exception if a GPU cannot be created. + */ +export type GPUProvider = () => GPU; + +let gpuProvider: GPUProvider = defaultGPUProvider; + +/** + * Sets the function to create and return a new GPU instance. + */ +export function setGPUProvider(provider: GPUProvider) { + assert(impl === undefined, 'setGPUProvider() should not be after getGPU()'); + gpuProvider = provider; +} + +let impl: GPU | undefined = undefined; + +let defaultRequestAdapterOptions: GPURequestAdapterOptions | undefined; + +export function setDefaultRequestAdapterOptions(options: GPURequestAdapterOptions) { + if (impl) { + throw new Error('must call setDefaultRequestAdapterOptions before getGPU'); + } + defaultRequestAdapterOptions = { ...options }; +} + +/** + * Finds and returns the `navigator.gpu` object (or equivalent, for non-browser implementations). + * Throws an exception if not found. + */ +export function getGPU(): GPU { + if (impl) { + return impl; + } + + impl = gpuProvider(); + + if (defaultRequestAdapterOptions) { + // eslint-disable-next-line @typescript-eslint/unbound-method + const oldFn = impl.requestAdapter; + impl.requestAdapter = function ( + options?: GPURequestAdapterOptions + ): Promise<GPUAdapter | null> { + const promise = oldFn.call(this, { ...defaultRequestAdapterOptions, ...(options || {}) }); + void promise.then(async adapter => { + if (adapter) { + const info = await adapter.requestAdapterInfo(); + // eslint-disable-next-line no-console + console.log(info); + } + }); + return promise; + }; + } + + return impl; +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/preprocessor.ts b/dom/webgpu/tests/cts/checkout/src/common/util/preprocessor.ts new file mode 100644 index 0000000000..7dc2822498 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/preprocessor.ts @@ -0,0 +1,149 @@ +import { assert } from './util.js'; + +// The state of the preprocessor is a stack of States. +type StateStack = { allowsFollowingElse: boolean; state: State }[]; +const enum State { + Seeking, // Still looking for a passing condition + Passing, // Currently inside a passing condition (the root is always in this state) + Skipping, // Have already seen a passing condition; now skipping the rest +} + +// The transitions in the state space are the following preprocessor directives: +// - Sibling elif +// - Sibling else +// - Sibling endif +// - Child if +abstract class Directive { + private readonly depth: number; + + constructor(depth: number) { + this.depth = depth; + } + + protected checkDepth(stack: StateStack): void { + assert( + stack.length === this.depth, + `Number of "$"s must match nesting depth, currently ${stack.length} (e.g. $if $$if $$endif $endif)` + ); + } + + abstract applyTo(stack: StateStack): void; +} + +class If extends Directive { + private readonly predicate: boolean; + + constructor(depth: number, predicate: boolean) { + super(depth); + this.predicate = predicate; + } + + applyTo(stack: StateStack) { + this.checkDepth(stack); + const parentState = stack[stack.length - 1].state; + stack.push({ + allowsFollowingElse: true, + state: + parentState !== State.Passing + ? State.Skipping + : this.predicate + ? State.Passing + : State.Seeking, + }); + } +} + +class ElseIf extends If { + applyTo(stack: StateStack) { + assert(stack.length >= 1); + const { allowsFollowingElse, state: siblingState } = stack.pop()!; + this.checkDepth(stack); + assert(allowsFollowingElse, 'pp.elif after pp.else'); + if (siblingState !== State.Seeking) { + stack.push({ allowsFollowingElse: true, state: State.Skipping }); + } else { + super.applyTo(stack); + } + } +} + +class Else extends Directive { + applyTo(stack: StateStack) { + assert(stack.length >= 1); + const { allowsFollowingElse, state: siblingState } = stack.pop()!; + this.checkDepth(stack); + assert(allowsFollowingElse, 'pp.else after pp.else'); + stack.push({ + allowsFollowingElse: false, + state: siblingState === State.Seeking ? State.Passing : State.Skipping, + }); + } +} + +class EndIf extends Directive { + applyTo(stack: StateStack) { + stack.pop(); + this.checkDepth(stack); + } +} + +/** + * A simple template-based, non-line-based preprocessor implementing if/elif/else/endif. + * + * @example + * ``` + * const shader = pp` + * ${pp._if(expr)} + * const x: ${type} = ${value}; + * ${pp._elif(expr)} + * ${pp.__if(expr)} + * ... + * ${pp.__else} + * ... + * ${pp.__endif} + * ${pp._endif}`; + * ``` + * + * @param strings - The array of constant string chunks of the template string. + * @param ...values - The array of interpolated `${}` values within the template string. + */ +export function pp( + strings: TemplateStringsArray, + ...values: ReadonlyArray<Directive | string | number> +): string { + let result = ''; + const stateStack: StateStack = [{ allowsFollowingElse: false, state: State.Passing }]; + + for (let i = 0; i < values.length; ++i) { + const passing = stateStack[stateStack.length - 1].state === State.Passing; + if (passing) { + result += strings[i]; + } + + const value = values[i]; + if (value instanceof Directive) { + value.applyTo(stateStack); + } else { + if (passing) { + result += value; + } + } + } + assert(stateStack.length === 1, 'Unterminated preprocessor condition at end of file'); + result += strings[values.length]; + + return result; +} +pp._if = (predicate: boolean) => new If(1, predicate); +pp._elif = (predicate: boolean) => new ElseIf(1, predicate); +pp._else = new Else(1); +pp._endif = new EndIf(1); +pp.__if = (predicate: boolean) => new If(2, predicate); +pp.__elif = (predicate: boolean) => new ElseIf(2, predicate); +pp.__else = new Else(2); +pp.__endif = new EndIf(2); +pp.___if = (predicate: boolean) => new If(3, predicate); +pp.___elif = (predicate: boolean) => new ElseIf(3, predicate); +pp.___else = new Else(3); +pp.___endif = new EndIf(3); +// Add more if needed. diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/timeout.ts b/dom/webgpu/tests/cts/checkout/src/common/util/timeout.ts new file mode 100644 index 0000000000..13c3b7fb90 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/timeout.ts @@ -0,0 +1,7 @@ +/** Defined by WPT. Like `setTimeout`, but applies a timeout multiplier for slow test systems. */ +declare const step_timeout: undefined | typeof setTimeout; + +/** + * Equivalent of `setTimeout`, but redirects to WPT's `step_timeout` when it is defined. + */ +export const timeout = typeof step_timeout !== 'undefined' ? step_timeout : setTimeout; diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/types.ts b/dom/webgpu/tests/cts/checkout/src/common/util/types.ts new file mode 100644 index 0000000000..dfd5e4b5ea --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/types.ts @@ -0,0 +1,59 @@ +/** Forces a type to resolve its type definitions, to make it readable/debuggable. */ +export type ResolveType<T> = T extends object + ? T extends infer O + ? { [K in keyof O]: ResolveType<O[K]> } + : never + : T; + +/** Returns the type `true` iff X and Y are exactly equal */ +export type TypeEqual<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 + ? true + : false; + +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export function assertTypeTrue<T extends true>() {} + +/** + * Computes the intersection of a set of types, given the union of those types. + * + * From: https://stackoverflow.com/a/56375136 + */ +export type UnionToIntersection<U> = + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +/** "Type asserts" that `X` is a subtype of `Y`. */ +type EnsureSubtype<X, Y> = X extends Y ? X : never; + +type TupleHeadOr<T, Default> = T extends readonly [infer H, ...(readonly unknown[])] ? H : Default; +type TupleTailOr<T, Default> = T extends readonly [unknown, ...infer Tail] ? Tail : Default; +type TypeOr<T, Default> = T extends undefined ? Default : T; + +/** + * Zips a key tuple type and a value tuple type together into an object. + * + * @template Keys Keys of the resulting object. + * @template Values Values of the resulting object. If a key corresponds to a `Values` member that + * is undefined or past the end, it defaults to the corresponding `Defaults` member. + * @template Defaults Default values. If a key corresponds to a `Defaults` member that is past the + * end, the default falls back to `undefined`. + */ +export type ZipKeysWithValues< + Keys extends readonly string[], + Values extends readonly unknown[], + Defaults extends readonly unknown[] +> = + // + Keys extends readonly [infer KHead, ...infer KTail] + ? { + readonly [k in EnsureSubtype<KHead, string>]: TypeOr< + TupleHeadOr<Values, undefined>, + TupleHeadOr<Defaults, undefined> + >; + } & + ZipKeysWithValues< + EnsureSubtype<KTail, readonly string[]>, + TupleTailOr<Values, []>, + TupleTailOr<Defaults, []> + > + : {}; // K exhausted diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/util.ts b/dom/webgpu/tests/cts/checkout/src/common/util/util.ts new file mode 100644 index 0000000000..f775c3c634 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/util.ts @@ -0,0 +1,303 @@ +import { Float16Array } from '../../external/petamoriken/float16/float16.js'; +import { globalTestConfig } from '../framework/test_config.js'; +import { Logger } from '../internal/logging/logger.js'; + +import { keysOf } from './data_tables.js'; +import { timeout } from './timeout.js'; + +/** + * Error with arbitrary `extra` data attached, for debugging. + * The extra data is omitted if not running the test in debug mode (`?debug=1`). + */ +export class ErrorWithExtra extends Error { + readonly extra: { [k: string]: unknown }; + + /** + * `extra` function is only called if in debug mode. + * If an `ErrorWithExtra` is passed, its message is used and its extras are passed through. + */ + constructor(message: string, extra: () => {}); + constructor(base: ErrorWithExtra, newExtra: () => {}); + constructor(baseOrMessage: string | ErrorWithExtra, newExtra: () => {}) { + const message = typeof baseOrMessage === 'string' ? baseOrMessage : baseOrMessage.message; + super(message); + + const oldExtras = baseOrMessage instanceof ErrorWithExtra ? baseOrMessage.extra : {}; + this.extra = Logger.globalDebugMode + ? { ...oldExtras, ...newExtra() } + : { omitted: 'pass ?debug=1' }; + } +} + +/** + * Asserts `condition` is true. Otherwise, throws an `Error` with the provided message. + */ +export function assert(condition: boolean, msg?: string | (() => string)): asserts condition { + if (!condition) { + throw new Error(msg && (typeof msg === 'string' ? msg : msg())); + } +} + +/** If the argument is an Error, throw it. Otherwise, pass it back. */ +export function assertOK<T>(value: Error | T): T { + if (value instanceof Error) { + throw value; + } + return value; +} + +/** + * Resolves if the provided promise rejects; rejects if it does not. + */ +export async function assertReject(p: Promise<unknown>, msg?: string): Promise<void> { + try { + await p; + unreachable(msg); + } catch (ex) { + // Assertion OK + } +} + +/** + * Assert this code is unreachable. Unconditionally throws an `Error`. + */ +export function unreachable(msg?: string): never { + throw new Error(msg); +} + +/** + * The `performance` interface. + * It is available in all browsers, but it is not in scope by default in Node. + */ +const perf = typeof performance !== 'undefined' ? performance : require('perf_hooks').performance; + +/** + * Calls the appropriate `performance.now()` depending on whether running in a browser or Node. + */ +export function now(): number { + return perf.now(); +} + +/** + * Returns a promise which resolves after the specified time. + */ +export function resolveOnTimeout(ms: number): Promise<void> { + return new Promise(resolve => { + timeout(() => { + resolve(); + }, ms); + }); +} + +export class PromiseTimeoutError extends Error {} + +/** + * Returns a promise which rejects after the specified time. + */ +export function rejectOnTimeout(ms: number, msg: string): Promise<never> { + return new Promise((_resolve, reject) => { + timeout(() => { + reject(new PromiseTimeoutError(msg)); + }, ms); + }); +} + +/** + * Takes a promise `p`, and returns a new one which rejects if `p` takes too long, + * and otherwise passes the result through. + */ +export function raceWithRejectOnTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> { + if (globalTestConfig.noRaceWithRejectOnTimeout) { + return p; + } + // Setup a promise that will reject after `ms` milliseconds. We cancel this timeout when + // `p` is finalized, so the JavaScript VM doesn't hang around waiting for the timer to + // complete, once the test runner has finished executing the tests. + const timeoutPromise = new Promise((_resolve, reject) => { + const handle = timeout(() => { + reject(new PromiseTimeoutError(msg)); + }, ms); + p = p.finally(() => clearTimeout(handle)); + }); + return Promise.race([p, timeoutPromise]) as Promise<T>; +} + +/** + * Takes a promise `p` and returns a new one which rejects if `p` resolves or rejects, + * and otherwise resolves after the specified time. + */ +export function assertNotSettledWithinTime( + p: Promise<unknown>, + ms: number, + msg: string +): Promise<undefined> { + // Rejects regardless of whether p resolves or rejects. + const rejectWhenSettled = p.then(() => Promise.reject(new Error(msg))); + // Resolves after `ms` milliseconds. + const timeoutPromise = new Promise<undefined>(resolve => { + const handle = timeout(() => { + resolve(undefined); + }, ms); + p.finally(() => clearTimeout(handle)); + }); + return Promise.race([rejectWhenSettled, timeoutPromise]); +} + +/** + * Returns a `Promise.reject()`, but also registers a dummy `.catch()` handler so it doesn't count + * as an uncaught promise rejection in the runtime. + */ +export function rejectWithoutUncaught<T>(err: unknown): Promise<T> { + const p = Promise.reject(err); + // Suppress uncaught promise rejection. + p.catch(() => {}); + return p; +} + +/** + * Makes a copy of a JS `object`, with the keys reordered into sorted order. + */ +export function sortObjectByKey(v: { [k: string]: unknown }): { [k: string]: unknown } { + const sortedObject: { [k: string]: unknown } = {}; + for (const k of Object.keys(v).sort()) { + sortedObject[k] = v[k]; + } + return sortedObject; +} + +/** + * Determines whether two JS values are equal, recursing into objects and arrays. + * NaN is treated specially, such that `objectEquals(NaN, NaN)`. + */ +export function objectEquals(x: unknown, y: unknown): boolean { + if (typeof x !== 'object' || typeof y !== 'object') { + if (typeof x === 'number' && typeof y === 'number' && Number.isNaN(x) && Number.isNaN(y)) { + return true; + } + return x === y; + } + if (x === null || y === null) return x === y; + if (x.constructor !== y.constructor) return false; + if (x instanceof Function) return x === y; + if (x instanceof RegExp) return x === y; + if (x === y || x.valueOf() === y.valueOf()) return true; + if (Array.isArray(x) && Array.isArray(y) && x.length !== y.length) return false; + if (x instanceof Date) return false; + if (!(x instanceof Object)) return false; + if (!(y instanceof Object)) return false; + + const x1 = x as { [k: string]: unknown }; + const y1 = y as { [k: string]: unknown }; + const p = Object.keys(x); + return Object.keys(y).every(i => p.indexOf(i) !== -1) && p.every(i => objectEquals(x1[i], y1[i])); +} + +/** + * Generates a range of values `fn(0)..fn(n-1)`. + */ +export function range<T>(n: number, fn: (i: number) => T): T[] { + return [...new Array(n)].map((_, i) => fn(i)); +} + +/** + * Generates a range of values `fn(0)..fn(n-1)`. + */ +export function* iterRange<T>(n: number, fn: (i: number) => T): Iterable<T> { + for (let i = 0; i < n; ++i) { + yield fn(i); + } +} + +/** Creates a (reusable) iterable object that maps `f` over `xs`, lazily. */ +export function mapLazy<T, R>(xs: Iterable<T>, f: (x: T) => R): Iterable<R> { + return { + *[Symbol.iterator]() { + for (const x of xs) { + yield f(x); + } + }, + }; +} + +const TypedArrayBufferViewInstances = [ + new Uint8Array(), + new Uint8ClampedArray(), + new Uint16Array(), + new Uint32Array(), + new Int8Array(), + new Int16Array(), + new Int32Array(), + new Float16Array(), + new Float32Array(), + new Float64Array(), +] as const; + +export type TypedArrayBufferView = typeof TypedArrayBufferViewInstances[number]; + +export type TypedArrayBufferViewConstructor< + A extends TypedArrayBufferView = TypedArrayBufferView +> = { + // Interface copied from Uint8Array, and made generic. + readonly prototype: A; + readonly BYTES_PER_ELEMENT: number; + + new (): A; + new (elements: Iterable<number>): A; + new (array: ArrayLike<number> | ArrayBufferLike): A; + new (buffer: ArrayBufferLike, byteOffset?: number, length?: number): A; + new (length: number): A; + + from(arrayLike: ArrayLike<number>): A; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + from(arrayLike: Iterable<number>, mapfn?: (v: number, k: number) => number, thisArg?: any): A; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + from<T>(arrayLike: ArrayLike<T>, mapfn: (v: T, k: number) => number, thisArg?: any): A; + of(...items: number[]): A; +}; + +export const kTypedArrayBufferViews: { + readonly [k: string]: TypedArrayBufferViewConstructor; +} = { + ...(() => { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const result: { [k: string]: any } = {}; + for (const v of TypedArrayBufferViewInstances) { + result[v.constructor.name] = v.constructor; + } + return result; + })(), +}; +export const kTypedArrayBufferViewKeys = keysOf(kTypedArrayBufferViews); +export const kTypedArrayBufferViewConstructors = Object.values(kTypedArrayBufferViews); + +function subarrayAsU8( + buf: ArrayBuffer | TypedArrayBufferView, + { start = 0, length }: { start?: number; length?: number } +): Uint8Array | Uint8ClampedArray { + if (buf instanceof ArrayBuffer) { + return new Uint8Array(buf, start, length); + } else if (buf instanceof Uint8Array || buf instanceof Uint8ClampedArray) { + // Don't wrap in new views if we don't need to. + if (start === 0 && (length === undefined || length === buf.byteLength)) { + return buf; + } + } + const byteOffset = buf.byteOffset + start * buf.BYTES_PER_ELEMENT; + const byteLength = + length !== undefined + ? length * buf.BYTES_PER_ELEMENT + : buf.byteLength - (byteOffset - buf.byteOffset); + return new Uint8Array(buf.buffer, byteOffset, byteLength); +} + +/** + * Copy a range of bytes from one ArrayBuffer or TypedArray to another. + * + * `start`/`length` are in elements (or in bytes, if ArrayBuffer). + */ +export function memcpy( + src: { src: ArrayBuffer | TypedArrayBufferView; start?: number; length?: number }, + dst: { dst: ArrayBuffer | TypedArrayBufferView; start?: number } +): void { + subarrayAsU8(dst.dst, dst).set(subarrayAsU8(src.src, src)); +} diff --git a/dom/webgpu/tests/cts/checkout/src/common/util/wpt_reftest_wait.ts b/dom/webgpu/tests/cts/checkout/src/common/util/wpt_reftest_wait.ts new file mode 100644 index 0000000000..7d10520bcb --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/util/wpt_reftest_wait.ts @@ -0,0 +1,24 @@ +import { timeout } from './timeout.js'; + +// Copied from https://github.com/web-platform-tests/wpt/blob/master/common/reftest-wait.js + +/** + * Remove the `reftest-wait` class on the document element. + * The reftest runner will wait with taking a screenshot while + * this class is present. + * + * See https://web-platform-tests.org/writing-tests/reftests.html#controlling-when-comparison-occurs + */ +export function takeScreenshot() { + document.documentElement.classList.remove('reftest-wait'); +} + +/** + * Call `takeScreenshot()` after a delay of at least `ms` milliseconds. + * @param {number} ms - milliseconds + */ +export function takeScreenshotDelayed(ms: number) { + timeout(() => { + takeScreenshot(); + }, ms); +} |