From 0b0a0f9bd0203526d2184f62836d2237f1d137df Mon Sep 17 00:00:00 2001 From: domidex01 <277685326+domidex01@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:02:25 +0700 Subject: [PATCH] The gate discovers its tests instead of reciting them npm test was a && chain of 105 npm run links: a fresh npm per file, strictly serial, dead at the first failure, and a hand-edit in two places for every new test. On Windows it died at link 15 and the other ninety never ran. scripts/test.js discovers test/*.js, runs the files in parallel (they all use listen(0) and mkdtemp, so they don't collide), prints every failure rather than the first, and exits non-zero if any failed. *.test.js files go through node --test, thumbs.js through electron, everything else through plain node. Arguments filter by filename substring; --jobs=N caps the workers. Discovery also picks up doc-defaults.js, a test that existed but had never been added to the chain. The per-file npm scripts remain for running one test at a time. --- package.json | 2 +- scripts/test.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 scripts/test.js diff --git a/package.json b/package.json index c924dcc3..35fd42ea 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "dist:mac": "npm run build && electron-builder --mac", "dist:win": "npm run build && electron-builder --win", "dist:mac:unsigned": "npm run build && electron-builder --mac -c.mac.forceCodeSigning=false -c.mac.notarize=false -c.mac.identity=null", - "test": "npm run test:markers && npm run test:apprenders && npm run test:bridge && npm run test:outlines && npm run test:navigator && npm run test:classattr && npm run test:gridtracks && npm run test:windowbounds && npm run test:transformunits && npm run test:popoverdismiss && npm run test:nodestates && npm run test:spacing && npm run test:gapbands && npm run test:gaphover && npm run test:popoverdropdown && npm run test:panelfield && npm run test:branchname && npm run test:sound && npm run test:panelsound && npm run test:segmented && npm run test:coloralpha && npm run test:colorlive && npm run test:enterstays && npm run test:popupscroll && npm run test:focus && npm run test:tokenfield && npm run test:canvasclick && npm run test:gitbranches && npm run test:conflicts && npm run test:githistory && npm run test:historypanel && npm run test:undoreach && npm run test:filebrowser && npm run test:assetdelete && npm run test:gradient && npm run test:gitsnapshot && npm run test:previewworktree && npm run test:starter && npm run test:content && npm run test:entries && npm run test:refs && npm run test:fields && npm run test:view && npm run test:panel && npm run test:dropdown && npm run test:injectedroutes && npm run test:csscode && npm run test:computedcolor && npm run test:styletarget && npm run test:thumbs && npm run test:welcome && npm run test:cssvars && npm run test:cssvarsrename && npm run test:fluid && npm run test:varsview && npm run test:varsrowheight && npm run test:varsdrop && npm run test:customvalue && npm run test:codeprop && npm run test:arrayvalue && npm run test:listfield && npm run test:insertbinding && npm run test:frontmattermove && npm run test:slotmove && npm run test:movingpage && npm run test:pageorder && npm run test:closeproject && npm run test:outsideedit && npm run test:hovercost && npm run test:fragmentidentity && npm run test:insertsearch && npm run test:varconnectopen && npm run test:insetpresets && npm run test:hideable && npm run test:transformsettings && npm run test:fieldfocus && npm run test:panelsurface && npm run test:computedhold && npm run test:sectiondot && npm run test:previewrecovery && npm run test:morph && npm run test:openedclass && npm run test:openedclick && npm run test:instanceroot && npm run test:chipedit && npm run test:jsguard && npm run test:spacingpick && npm run test:rawattrs && npm run test:richspan && npm run test:varspanel && npm run test:binding && npm run test:loopsource && npm run test:selectorslive && npm run test:selectorwell && npm run test:popuplayers && npm run test:scopecompletions && npm run test:querylist && npm run test:codesearch && npm run test:componentcreate && npm run test:componentfolders && npm run test:commentregion && npm run test:condbranches && npm run test:instanceprops && npm run test:variantoptions && npm run test:panelsections && npm run test:htmltext && npm run test:roundtrip" + "test": "node scripts/test.js" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 00000000..7aaed283 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// +// Run the whole test suite. +// +// node scripts/test.js — everything under test/ +// node scripts/test.js focus color — only files whose name contains a term +// node scripts/test.js --jobs=4 — cap the number of parallel workers +// +// `npm test` used to be a && chain of 105 `npm run` links: a fresh npm per +// file, strictly serial, dead at the first failure — and a hand-edit in two +// places for every new test. This discovers test/*.js instead, so a new test +// file is in the gate the moment it exists. Files run in parallel (they all +// use listen(0) and mkdtemp, so they don't collide), every failure is +// reported rather than the first, and the exit code says whether any failed. +// +// The per-file npm scripts (test:focus, …) remain for running one at a time. + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const TEST_DIR = path.join(__dirname, '..', 'test'); + +// A few files need a different launcher than plain `node file`: +// - *.test.js are node:test suites and want the test runner's reporter +// - thumbs.js exercises Electron's nativeImage, so it needs the real binary +// Everything else runs as a plain script; the --disable-warning flag keeps +// the typeless-package.json warning out of a hundred captured outputs. +function commandFor(file) { + if (file.endsWith('.test.js')) { + return [process.execPath, ['--test', path.join('test', file)]]; + } + if (file === 'thumbs.js') { + return [require('electron'), [path.join('test', file)]]; + } + return [ + process.execPath, + ['--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', path.join('test', file)], + ]; +} + +function runOne(file) { + return new Promise((resolve) => { + const [bin, args] = commandFor(file); + const started = Date.now(); + const child = spawn(bin, args, { + cwd: path.join(__dirname, '..'), + env: process.env, + }); + const chunks = []; + child.stdout.on('data', (d) => chunks.push(d)); + child.stderr.on('data', (d) => chunks.push(d)); + child.on('error', (err) => { + resolve({ file, ok: false, output: String(err), ms: Date.now() - started }); + }); + child.on('close', (code) => { + resolve({ + file, + ok: code === 0, + output: Buffer.concat(chunks).toString('utf8'), + ms: Date.now() - started, + }); + }); + }); +} + +async function main() { + const terms = []; + let jobs = Math.min(8, os.availableParallelism()); + for (const arg of process.argv.slice(2)) { + const m = /^--jobs=(\d+)$/.exec(arg); + if (m) jobs = Math.max(1, Number(m[1])); + else terms.push(arg.toLowerCase()); + } + + let files = fs + .readdirSync(TEST_DIR) + .filter((f) => f.endsWith('.js')) + .sort(); + if (terms.length) { + files = files.filter((f) => terms.some((t) => f.toLowerCase().includes(t))); + } + if (!files.length) { + console.error(`no test files match: ${terms.join(' ')}`); + process.exit(1); + } + + const failures = []; + let done = 0; + const queue = files.slice(); + const worker = async () => { + for (let file = queue.shift(); file; file = queue.shift()) { + const result = await runOne(file); + done += 1; + const count = `[${String(done).padStart(String(files.length).length)}/${files.length}]`; + if (result.ok) { + console.log(`${count} ok ${file} (${result.ms}ms)`); + } else { + failures.push(result); + console.log(`${count} FAIL ${file} (${result.ms}ms)`); + } + } + }; + await Promise.all(Array.from({ length: Math.min(jobs, files.length) }, worker)); + + for (const f of failures) { + console.log(`\n=== ${f.file} ===`); + console.log(f.output.trimEnd()); + } + console.log( + `\n${files.length - failures.length} passed, ${failures.length} failed` + + (failures.length ? `: ${failures.map((f) => f.file).join(', ')}` : '') + ); + process.exit(failures.length ? 1 : 0); +} + +main();