diff options
Diffstat (limited to 'dom/webgpu/tests/cts/checkout/src/common/framework')
7 files changed, 1127 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..c1e3a889be --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/data_cache.ts @@ -0,0 +1,197 @@ +/** + * 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). + */ + +import { assert } from '../util/util.js'; + +interface DataStore { + load(path: string): Promise<Uint8Array>; +} + +/** Logger is a basic debug logger function */ +export type Logger = (s: string) => void; + +/** + * DataCacheNode represents a single cache entry in the LRU DataCache. + * DataCacheNode is a doubly linked list, so that least-recently-used entries can be removed, and + * cache hits can move the node to the front of the list. + */ +class DataCacheNode { + public constructor(path: string, data: unknown) { + this.path = path; + this.data = data; + } + + /** insertAfter() re-inserts this node in the doubly-linked list after `prev` */ + public insertAfter(prev: DataCacheNode) { + this.unlink(); + this.next = prev.next; + this.prev = prev; + prev.next = this; + if (this.next) { + this.next.prev = this; + } + } + + /** unlink() removes this node from the doubly-linked list */ + public unlink() { + const prev = this.prev; + const next = this.next; + if (prev) { + prev.next = next; + } + if (next) { + next.prev = prev; + } + this.prev = null; + this.next = null; + } + + public readonly path: string; // The file path this node represents + public readonly data: unknown; // The deserialized data for this node + public prev: DataCacheNode | null = null; // The previous node in the doubly-linked list + public next: DataCacheNode | null = null; // The next node in the doubly-linked list +} + +/** DataCache is an interface to a LRU-cached data store used to hold data cached by path */ +export class DataCache { + public constructor() { + this.lruHeadNode.next = this.lruTailNode; + this.lruTailNode.prev = this.lruHeadNode; + } + + /** 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 + const node = this.cache.get(cacheable.path); + if (node !== undefined) { + this.log('in-memory cache hit'); + node.insertAfter(this.lruHeadNode); + return Promise.resolve(node.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: Uint8Array | 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`); + const data = cacheable.deserialize(serialized); + this.addToCache(cacheable.path, data); + return data; + } + } + // Not found anywhere. Build the data, and cache for future lookup. + this.log(`cache: building (${cacheable.path})`); + const data = await cacheable.build(); + this.addToCache(cacheable.path, data); + return data; + } + + /** + * addToCache() creates a new node for `path` and `data`, inserting the new node at the front of + * the doubly-linked list. If the number of entries in the cache exceeds this.maxCount, then the + * least recently used entry is evicted + * @param path the file path for the data + * @param data the deserialized data + */ + private addToCache(path: string, data: unknown) { + if (this.cache.size >= this.maxCount) { + const toEvict = this.lruTailNode.prev; + assert(toEvict !== null); + toEvict.unlink(); + this.cache.delete(toEvict.path); + this.log(`evicting ${toEvict.path}`); + } + const node = new DataCacheNode(path, data); + node.insertAfter(this.lruHeadNode); + this.cache.set(path, node); + this.log(`added ${path}. new count: ${this.cache.size}`); + } + + private log(msg: string) { + if (this.debugLogger !== null) { + this.debugLogger(`DataCache: ${msg}`); + } + } + + // Max number of entries in the cache before LRU entries are evicted. + private readonly maxCount = 4; + + private cache = new Map<string, DataCacheNode>(); + private lruHeadNode = new DataCacheNode('', null); // placeholder node (no path or data) + private lruTailNode = new DataCacheNode('', null); // placeholder node (no path or data) + 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() encodes `data` to a binary representation so that it can be stored in a cache file. + */ + serialize(data: Data): Uint8Array; + + /** + * deserialize() is the inverse of serialize(), decoding the binary representation back to a Data + * object. + */ + deserialize(binary: Uint8Array): 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..77875e047d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/fixture.ts @@ -0,0 +1,370 @@ +import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js'; +import { JSONWithUndefined } from '../internal/params_utils.js'; +import { assert, ExceptionCheckOptions, 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 { + constructor( + protected readonly recorder: TestCaseRecorder, + /** The case parameters for this test fixture shared state. Subcase params are not included. */ + public readonly params: TestParams + ) {} + + /** + * 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 + */ + readonly rec: TestCaseRecorder; + private eventualExpectations: Array<Promise<unknown>> = []; + private numOutstandingAsyncExpectations = 0; + private objectsToCleanUp: DestroyableObject[] = []; + + public static MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchState { + return new SubcaseBatchState(recorder, 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); + } + + /** Throws an exception marking the subcase as skipped if condition is true */ + skipIf(cond: boolean, msg: string | (() => string) = '') { + if (cond) { + this.skip(typeof msg === 'function' ? msg() : 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>, + { allowMissingStack = false, message }: ExceptionCheckOptions = {} + ): void { + this.eventualAsyncExpectation(async niceStack => { + const m = message ? ': ' + message : ''; + try { + await p; + niceStack.message = 'DID NOT REJECT' + m; + this.rec.expectationFailed(niceStack); + } catch (ex) { + this.expectErrorValue(expectedName, ex, niceStack); + if (!allowMissingStack) { + if (!(ex instanceof Error && typeof ex.stack === 'string')) { + const exMessage = ex instanceof Error ? ex.message : '?'; + niceStack.message = `rejected as expected, but missing stack (${exMessage})${m}`; + this.rec.expectationFailed(niceStack); + } + } + } + }); + } + + /** + * Expect that the provided function throws (if `true` or `string`) or not (if `false`). + * If a string is provided, expect that the throw exception has that name. + * + * MAINTENANCE_TODO: Change to `string | false` so the exception name is always checked. + */ + shouldThrow( + expectedError: string | boolean, + fn: () => void, + { allowMissingStack = false, message }: ExceptionCheckOptions = {} + ) { + const m = message ? ': ' + message : ''; + 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)); + if (!allowMissingStack) { + if (!(ex instanceof Error && typeof ex.stack === 'string')) { + this.rec.expectationFailed(new Error('threw as expected, but missing stack' + 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 }); + }); + } +} + +export type SubcaseBatchStateFromFixture<F> = F extends Fixture<infer S> ? S : never; + +/** + * FixtureClass encapsulates a constructor for fixture and a corresponding + * shared state factory function. An interface version of the type is also + * defined for mixin declaration use ONLY. The interface version is necessary + * because mixin classes need a constructor with a single any[] rest + * parameter. + */ +export type FixtureClass<F extends Fixture = Fixture> = { + new (sharedState: SubcaseBatchStateFromFixture<F>, log: TestCaseRecorder, params: TestParams): F; + MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>; +}; +export type FixtureClassInterface<F extends Fixture = Fixture> = { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + new (...args: any[]): F; + MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>; +}; +export type FixtureClassWithMixin<FC, M> = FC extends FixtureClass<infer F> + ? FixtureClass<F & M> + : never; diff --git a/dom/webgpu/tests/cts/checkout/src/common/framework/metadata.ts b/dom/webgpu/tests/cts/checkout/src/common/framework/metadata.ts new file mode 100644 index 0000000000..2c2a1ef794 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/metadata.ts @@ -0,0 +1,28 @@ +import { assert } from '../util/util.js'; + +/** Metadata about tests (that can't be derived at runtime). */ +export type TestMetadata = { + /** + * Estimated average time-per-subcase, in milliseconds. + * This is used to determine chunking granularity when exporting to WPT with + * chunking enabled (like out-wpt/cts-chunked2sec.https.html). + */ + subcaseMS: number; +}; + +export type TestMetadataListing = { + [testQuery: string]: TestMetadata; +}; + +export function loadMetadataForSuite(suiteDir: string): TestMetadataListing | null { + assert(typeof require !== 'undefined', 'loadMetadataForSuite is only implemented on Node'); + const fs = require('fs'); + + const metadataFile = `${suiteDir}/listing_meta.json`; + if (!fs.existsSync(metadataFile)) { + return null; + } + + const metadata: TestMetadataListing = JSON.parse(fs.readFileSync(metadataFile, 'utf8')); + return metadata; +} 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..09a7d9c320 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/params_builder.ts @@ -0,0 +1,389 @@ +import { Merged, mergeParams, mergeParamsChecked } from '../internal/params_utils.js'; +import { comparePublicParamsPaths, Ordering } from '../internal/query/compare.js'; +import { stringifyPublicParams } from '../internal/query/stringify_params.js'; +import { DeepReadonly } from '../util/types.js'; +import { assert, mapLazy, objectEquals } from '../util/util.js'; + +import { TestParams } from './fixture.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 [DeepReadonly<CaseP>, Iterable<DeepReadonly<SubcaseP>> | undefined] +>; + +/** + * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`. + */ +export abstract class ParamsBuilderBase<CaseP extends {}, SubcaseP extends {}> { + protected readonly cases: (caseFilter: TestParams | null) => Generator<CaseP>; + + constructor(cases: (caseFilter: TestParams | null) => Generator<CaseP>) { + this.cases = cases; + } + + /** + * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this. + */ + protected abstract iterateCasesWithSubcases( + caseFilter: TestParams | null + ): CaseSubcaseIterable<CaseP, SubcaseP>; +} + +/** + * Calls the (normally hidden) `iterateCasesWithSubcases()` method. + */ +export function builderIterateCasesWithSubcases( + builder: ParamsBuilderBase<{}, {}>, + caseFilter: TestParams | null +) { + interface IterableParamsBuilder { + iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<{}, {}>; + } + + return (builder as unknown as IterableParamsBuilder).iterateCasesWithSubcases(caseFilter); +} + +/** + * 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<DeepReadonly<CaseP>>, ParamsBuilder +{ + *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, {}> { + for (const caseP of this.cases(caseFilter)) { + if (caseFilter) { + // this.cases() only filters out cases which conflict with caseFilter. Now that we have + // the final caseP, filter out cases which are missing keys that caseFilter requires. + const ordering = comparePublicParamsPaths(caseP, caseFilter); + if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { + continue; + } + } + + yield [caseP as DeepReadonly<typeof caseP>, undefined]; + } + } + + [Symbol.iterator](): Iterator<DeepReadonly<CaseP>> { + return this.cases(null) as Iterator<DeepReadonly<CaseP>>; + } + + /** @inheritDoc */ + expandWithParams<NewP extends {}>( + expander: (_: CaseP) => Iterable<NewP> + ): CaseParamsBuilder<Merged<CaseP, NewP>> { + const baseGenerator = this.cases; + return new CaseParamsBuilder(function* (caseFilter) { + for (const a of baseGenerator(caseFilter)) { + for (const b of expander(a)) { + if (caseFilter) { + // If the expander generated any key-value pair that conflicts with caseFilter, skip. + const kvPairs = Object.entries(b); + if (kvPairs.some(([k, v]) => k in caseFilter && !objectEquals(caseFilter[k], v))) { + continue; + } + } + + yield mergeParamsChecked(a, b); + } + } + }); + } + + /** @inheritDoc */ + expand<NewPKey extends string, NewPValue>( + key: NewPKey, + expander: (_: CaseP) => Iterable<NewPValue> + ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { + const baseGenerator = this.cases; + return new CaseParamsBuilder(function* (caseFilter) { + for (const a of baseGenerator(caseFilter)) { + assert(!(key in a), `New key '${key}' already exists in ${JSON.stringify(a)}`); + + for (const v of expander(a)) { + // If the expander generated a value for this key that conflicts with caseFilter, skip. + if (caseFilter && key in caseFilter) { + if (!objectEquals(caseFilter[key], v)) { + continue; + } + } + yield { ...a, [key]: v } as Merged<CaseP, { [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: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> { + const baseGenerator = this.cases; + return new CaseParamsBuilder(function* (caseFilter) { + for (const a of baseGenerator(caseFilter)) { + if (pred(a)) yield a; + } + }); + } + + /** @inheritDoc */ + unless(pred: (_: 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: (caseFilter: TestParams | null) => Generator<CaseP>, + generator: (_: CaseP) => Generator<SubcaseP> + ) { + super(cases); + this.subcases = generator; + } + + *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, SubcaseP> { + for (const caseP of this.cases(caseFilter)) { + if (caseFilter) { + // this.cases() only filters out cases which conflict with caseFilter. Now that we have + // the final caseP, filter out cases which are missing keys that caseFilter requires. + const ordering = comparePublicParamsPaths(caseP, caseFilter); + if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { + continue; + } + } + + const subcases = Array.from(this.subcases(caseP)); + if (subcases.length) { + yield [ + caseP as DeepReadonly<typeof caseP>, + subcases as DeepReadonly<(typeof subcases)[number]>[], + ]; + } + } + } + + /** @inheritDoc */ + expandWithParams<NewP extends {}>( + expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewP> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { + const baseGenerator = this.subcases; + return new SubcaseParamsBuilder(this.cases, function* (base) { + for (const a of baseGenerator(base)) { + for (const b of expander(mergeParams(base, a))) { + yield mergeParamsChecked(a, b); + } + } + }); + } + + /** @inheritDoc */ + expand<NewPKey extends string, NewPValue>( + key: NewPKey, + expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewPValue> + ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { + const baseGenerator = this.subcases; + return new SubcaseParamsBuilder(this.cases, function* (base) { + for (const a of baseGenerator(base)) { + const before = mergeParams(base, a); + assert(!(key in before), () => `Key '${key}' already exists in ${JSON.stringify(before)}`); + + for (const v of expander(before)) { + yield { ...a, [key]: v } as Merged<SubcaseP, { [k 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> { + const baseGenerator = this.subcases; + return new SubcaseParamsBuilder(this.cases, function* (base) { + for (const a of baseGenerator(base)) { + if (pred(mergeParams(base, a))) yield a; + } + }); + } + + /** @inheritDoc */ + unless(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { + return this.filter(x => !pred(x)); + } +} + +/** 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..2575418299 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/common/framework/test_config.ts @@ -0,0 +1,32 @@ +export type TestConfig = { + maxSubcasesInFlight: number; + testHeartbeatCallback: () => void; + noRaceWithRejectOnTimeout: boolean; + + /** + * Logger for debug messages from the test framework + * (that can't be captured in the logs of a test). + */ + frameworkDebugLog?: (msg: string) => void; + + /** + * 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; + + /** + * Whether or not we're running in compatibility mode. + */ + compatibility: boolean; +}; + +export const globalTestConfig: TestConfig = { + maxSubcasesInFlight: 500, + testHeartbeatCallback: () => {}, + noRaceWithRejectOnTimeout: false, + unrollConstEvalLoops: false, + compatibility: 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'; |