Files
ttstd-infinite-canvas/tools/test-gen-asset-upstream.cjs
2026-10-06 09:24:06 +08:00

136 lines
7.2 KiB
JavaScript

// Run from web with a test/*.test.ts argument. Execute upstream suites with Node.
// Assertions and
// module mocks stay in the original suites; unsupported matchers fail closed.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const root = path.resolve(process.cwd(), '..');
const web = createRequire(path.join(root, 'web/package.json'));
const { transformSync } = web('rolldown/experimental');
const babel = web('@babel/core');
const paths = JSON.parse(fs.readFileSync('tsconfig.json', 'utf8')).compilerOptions.paths;
const cache = new Map(), mocks = new Map(), tests = [];
function resolve(name, parent) {
for (const [alias, targets] of Object.entries(paths)) {
if (alias.endsWith('*') ? name.startsWith(alias.slice(0, -1)) : name === alias) {
name = path.resolve(targets[0].replace('*', name.slice(alias.length - 1)));
break;
}
}
if (!name.startsWith('.') && !path.isAbsolute(name)) return name;
const base = path.resolve(path.dirname(parent), name);
for (const ext of ['', '.ts', '.tsx', '.js', '.json', '/index.ts', '/index.tsx']) {
if (fs.existsSync(base + ext) && fs.statSync(base + ext).isFile()) return base + ext;
}
throw Error('Cannot resolve ' + name + ' from ' + parent);
}
const stringPattern = Symbol('stringMatching');
function resolveMatchers(value, expected) {
if (expected?.[stringPattern]) {
assert.equal(typeof value, 'string');
assert.match(value, expected[stringPattern]);
return value;
}
if (Array.isArray(expected)) return expected.map((item, index) => resolveMatchers(value?.[index], item));
if (expected && Object.getPrototypeOf(expected) === Object.prototype) {
return Object.fromEntries(Object.entries(expected).map(([key, item]) => [key, resolveMatchers(value?.[key], item)]));
}
return expected;
}
function expect(value, label = '') {
const matchers = (negate = false) => {
const check = (fn) => {
let error;
try { fn(); } catch (caught) { error = caught; }
if (negate) assert(error, 'expect(...).not ' + label);
else if (error) throw new Error('expect(...) ' + label + ': ' + error.message, { cause: error });
};
return {
get not() { return matchers(!negate); },
toBe: other => check(() => assert.equal(value, other)),
toEqual: other => check(() => assert.deepEqual(value, resolveMatchers(value, other))),
toBeNull: () => check(() => assert.equal(value, null)),
toBeUndefined: () => check(() => assert.equal(value, undefined)),
toBeDefined: () => check(() => assert.notEqual(value, undefined)),
toHaveLength: other => check(() => assert.equal(value.length, other)),
toHaveBeenCalled: () => check(() => assert(value.calls.length > 0)),
toHaveBeenCalledTimes: other => check(() => assert.equal(value.calls.length, other)),
toContain: other => check(() => assert(value.includes(other))),
toBeLessThan: other => check(() => assert(value < other)),
toBeGreaterThanOrEqual: other => check(() => assert(value >= other)),
toMatchObject: other => check(() => assert.partialDeepStrictEqual(value, other)),
toThrow: message => check(() => assert.throws(value, error => message === undefined || (message instanceof RegExp ? message.test(error.message) : error.message.includes(message)))),
get rejects() { return { toThrow: async message => {
let rejected = false;
try { await value; } catch (error) { rejected = true; expect(() => { throw error; }, label).toThrow(message); }
assert(rejected, 'expect(...).rejects ' + label);
} }; },
get resolves() { return new Proxy({}, { get: (_, method) => async (...args) => expect(await value)[method](...args) }); },
};
};
return matchers();
}
const entry = path.resolve(process.argv[2]);
expect.stringMatching = pattern => ({ [stringPattern]: pattern instanceof RegExp ? pattern : new RegExp(pattern) });
let beforeEach = [], afterEach = [];
const api = { expect, test: (name, fn) => {
const setup = [...beforeEach];
const teardown = [...afterEach];
tests.push([name, async () => { try { for (const run of setup) await run(); await fn(); } finally { for (const run of teardown) await run(); } }]);
}, spyOn(object, method) {
const original = object[method];
let implementation = original;
const spy = Object.assign(function (...args) { spy.calls.push(args); return implementation.apply(this, args); }, { calls: [], mockImplementation(fn) { implementation = fn; return spy; }, mockResolvedValue(value) { implementation = async () => value; return spy; }, mockRestore() { object[method] = original; } });
object[method] = spy;
return spy;
}, afterEach: fn => afterEach.push(fn), beforeEach: fn => beforeEach.push(fn), describe: (_name, run) => {
const parent = beforeEach;
beforeEach = [...parent];
try { run(); } finally { beforeEach = parent; }
}, mock: { module(name, factory) {
mocks.set(resolve(name, entry), factory());
} } };
function compile(file) {
const source = fs.readFileSync(file, 'utf8').replaceAll('import.meta.env', '({})');
const result = transformSync(file, source, { jsx: { runtime: 'automatic' } });
assert.equal(result.errors.length, 0, JSON.stringify(result.errors));
return babel.transformSync(result.code, { filename: file, configFile: false, babelrc: false,
plugins: [web.resolve('@babel/plugin-transform-modules-commonjs'), ({ types: t }) => ({ visitor: {
CallExpression(node) {
if (node.node.callee.type === 'Import') node.replaceWith(t.callExpression(
t.memberExpression(t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('resolve')), []), t.identifier('then')),
[t.arrowFunctionExpression([], t.callExpression(t.identifier('require'), node.node.arguments))]));
},
} })],
}).code;
}
function load(name, parent = entry) {
if (name === 'bun:test' || name === 'node:test') return api;
const file = resolve(name, parent);
if (mocks.has(file)) return mocks.get(file);
if (!path.isAbsolute(file)) return web(file);
if (file.endsWith('.css')) return {};
if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes('/node_modules/') || file.endsWith('.mjs')) return createRequire(parent)(file);
if (cache.has(file)) return cache.get(file).exports;
const module = { exports: {} };
cache.set(file, module);
new Function('require', 'module', 'exports', '__filename', '__dirname', compile(file))(
name => load(name, file), module, module.exports, file, path.dirname(file));
return module.exports;
}
(async () => {
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const module = { exports: {} };
await new AsyncFunction('require', 'module', 'exports', '__filename', '__dirname', compile(entry))(
name => load(name, entry), module, module.exports, entry, path.dirname(entry));
assert(tests.length > 0, 'No tests registered');
let failed = 0;
for (const [name, fn] of tests) {
try { await fn(); console.log('(pass) ' + name); }
catch (error) { failed++; console.error('(fail) ' + name, error); }
}
console.log(`${tests.length - failed} pass\n ${failed} fail (Node)`);
process.exitCode = failed ? 1 : 0;
})().catch(error => { console.error(error); process.exitCode = 1; });