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 1/2] 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(); From 0695a2c35408ca76d2a988a230b3c7047cf97f7a Mon Sep 17 00:00:00 2001 From: domidex01 <277685326+domidex01@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:26:17 +0700 Subject: [PATCH 2/2] The tests run on machines the maintainer doesn't own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only workflow was release.yml, which triggers on v* tags and never runs npm test — nothing between two releases ever executed the suite anywhere but the machine it was written on. On a fresh Windows checkout of main, 10 of 110 test files fail and the repository shows no sign of it, because none of them fail on macOS. ci.yml runs npm ci && npm test on ubuntu, macos and windows for every pull request and every push to main, fail-fast off so one platform's red doesn't hide another's. The Linux leg gets xvfb and lifts Ubuntu 24.04's AppArmor user-namespace restriction, because thumbs.js opens a real Electron window. The Windows leg will be red on day one. That is the finding, not a flaw in the workflow: the ten failures it shows are the ones currently invisible from macOS. --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e7028f14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + # thumbs.js opens a real Electron window, so the Linux runner needs a + # display, and Ubuntu 24.04's AppArmor blocks Electron's unprivileged + # user-namespace sandbox unless the restriction is lifted. + - name: Test (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -q kernel.apparmor_restrict_unprivileged_userns=0 + xvfb-run -a npm test + - name: Test + if: runner.os != 'Linux' + run: npm test