Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,19 @@ jobs:
- run: git diff --check

tests:
name: Tests (${{ matrix.os }}, ${{ matrix.shard }}/2)
name: Tests (${{ matrix.os }}, ${{ matrix.shard }}/3)
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 10
# Three shards per OS: test steps measured 4.9-6.6 minutes and whole jobs 6-7.8 minutes on 2026-10-22 (two shards had
# reached 8-10 minutes against a 10-minute cap). The cap is about 1.5x the slowest job, so slow macOS runners pass and a
# hang still fails the job; every test keeps its own 120 s timeout.
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-24.04]
shard: [1, 2]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -101,7 +104,7 @@ jobs:
echo "NMSH_TEST_MERMAID=$dir/package" >> "$GITHUB_ENV"
# Built-launcher tests must execute rather than silently skip on fresh runners.
- run: npm run build
- run: npm test -- --shard=${{ matrix.shard }}/2 --test-timeout=120000
- run: npm test -- --shard=${{ matrix.shard }}/3 --test-timeout=120000
- name: Process leak check
if: always()
run: bash scripts/check-process-leaks.sh
Expand Down
11 changes: 7 additions & 4 deletions docs/development-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ Local commands reuse node_modules. Clean CI/release environments use

## Canonical suite and sharding

The only shard interface is `npm test -- --shard=1/2` (or `2/2`). No arguments
The only shard interface is `npm test -- --shard=<index>/<count>` (CI uses `1/3`, `2/3`, `3/3`). No arguments
still runs every recursively discovered `tests/**/*.test.ts` file. File selection
lives in `scripts/test-selection.mjs`; its tests verify deterministic assignment,
exact union, no overlap, invalid-input rejection, and curated-list existence.
Runtime files are partitioned as whole files by deterministic greedy scheduling.
Rounded measured weights for slow files balance PTY work; ordinary/new files
default to one. Reprofile weights only when actual shard imbalance warrants it. `suggestionRanking.test.ts` runs
default to one. Reprofile weights only when actual shard imbalance warrants it: the per-test durations in the
CI logs, attributed to their files, are the source (last done 2026-10-22, when unweighted browser and live files had pushed
shards to 8–10 minutes of a 10-minute cap). Tests that drive a real Chromium share one browser per file where they test
pages rather than the browser's own lifecycle (`sharedLab()` in `tests/support/browserFixtures.ts`). `suggestionRanking.test.ts` runs
separately after runtime files, only in shard one, preserving uncontested latency
measurement. New files automatically enter the canonical suite and a shard.

Expand All @@ -40,8 +43,8 @@ session scenarios run fully on Node 26 on both macOS and Ubuntu.

## CI gates

Code PRs run quality on Ubuntu/Node 26, two macOS/Node 26 shards, two Ubuntu/Node
26 shards, and curated Ubuntu/Node 22 compatibility. Each shard builds because
Code PRs run quality on Ubuntu/Node 26, three macOS/Node 26 shards, three Ubuntu/Node
26 shards (12-minute cap each, about 1.5 times the slowest measured job, so a hang still fails), and curated Ubuntu/Node 22 compatibility. Each shard builds because
built-launcher tests otherwise skip on a clean checkout. Each integration shard
and smoke job checks helper-process leaks, including after a test failure.
Existing hard latency assertions remain in the canonical tests.
Expand Down
86 changes: 65 additions & 21 deletions scripts/test-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,80 @@ const suites = {
fedora: ['linuxPlatform', 'tools', 'update', 'hostProfiles', 'portabilityUninstall'],
};

// Rounded seconds from the macOS Node 26 canonical baseline (2026-10-04).
// Rounded seconds: the slower of the macOS and Ubuntu Node 26 CI shards (run 39756506927, 2026-10-22); files not
// measured there keep their 2026-10-04 macOS baseline. The two Chrome files are estimates after they began sharing one
// browser per file.
// Only slow files need weights; new/ordinary files default to one. Greedy
// whole-file scheduling avoids clustering the costly PTY files in one shard.
const runtimeWeights = {
"bundledCatalog.test.ts": 5,
"compatibilityHarness.test.ts": 28,
"commandQueueLive.test.ts": 93,
"copySelectionLive.test.ts": 13,
"configuredCompletion.test.ts": 6,
"ctrlZJobControl.test.ts": 12,
"detachedOutput.test.ts": 22,
"activityStyleLive.test.ts": 16,
"ansiLiveRegion.test.ts": 15,
"arcadeAll.test.ts": 8,
"arcadeBackgroundLive.test.ts": 52,
"arcadeDashGame.test.ts": 26,
"arcadeDashPhysics.test.ts": 26,
"arcadePinball.test.ts": 20,
"arcadeQueensLogic.test.ts": 15,
"arcadeSudokuLogic.test.ts": 6,
"backgroundSupervisor.test.ts": 11,
"browserEngineChrome.test.ts": 50,
"browserPanel.test.ts": 6,
"bundledCatalog.test.ts": 11,
"chromaV2.test.ts": 6,
"clipsLive.test.ts": 6,
"commandQueueLive.test.ts": 97,
"compareOutputLive.test.ts": 10,
"compatibilityHarness.test.ts": 47,
"configuredCompletion.test.ts": 13,
"contextEngine.test.ts": 13,
"contextHostileWorkspace.test.ts": 9,
"copyExactLive.test.ts": 7,
"copyPayload.test.ts": 5,
"copyReportLive.test.ts": 5,
"copySelectionLive.test.ts": 51,
"ctrlZJobControl.test.ts": 24,
"detachedOutput.test.ts": 38,
"diagramsChrome.test.ts": 25,
"flowComposer.test.ts": 8,
"fullChromaQa.test.ts": 5,
"hostProfiles.test.ts": 10,
"inputAwarenessLive.test.ts": 59,
"interactiveCli.test.ts": 14,
"liveHardening.test.ts": 45,
"liveStatus.test.ts": 5,
"muxInterop.test.ts": 31,
"nativeCaptureLifecycle.test.ts": 6,
"sessionLifecycle.test.ts": 28,
"sessionPresets.test.ts": 20,
"fullscreenFailure.test.ts": 8,
"fullscreenResizeSampling.test.ts": 10,
"historyReuseLive.test.ts": 11,
"hostProfiles.test.ts": 18,
"hybrid.test.ts": 6,
"inputAwarenessLive.test.ts": 52,
"inputOwnershipLive.test.ts": 46,
"interactiveCli.test.ts": 37,
"keepAwakeForeground.test.ts": 8,
"liveHardening.test.ts": 88,
"liveStatus.test.ts": 10,
"moduleDiagnosticsLive.test.ts": 7,
"muxInterop.test.ts": 47,
"nativeCaptureLifecycle.test.ts": 8,
"outputReferencesLive.test.ts": 7,
"pasteBatchLive.test.ts": 21,
"pinsLive.test.ts": 7,
"queueConditionsLive.test.ts": 24,
"queuePreviewLive.test.ts": 33,
"queueSummaryLive.test.ts": 23,
"sessionLifecycle.test.ts": 78,
"sessionPresets.test.ts": 45,
"shellAdapters.test.ts": 35,
"shellIntelligence.test.ts": 22,
"shellSwitchApp.test.ts": 23,
"startupBlocked.test.ts": 29,
"startupDiscovery.test.ts": 22,
"startupLaunch.test.ts": 8,
"startupBlocked.test.ts": 64,
"startupDiscovery.test.ts": 87,
"startupLaunch.test.ts": 10,
"startupLifecycle.test.ts": 7,
"terminalBaseline.test.ts": 9,
"terminalModeReconciliation.test.ts": 8,
"themeBridgeLive.test.ts": 5,
"tmpdirHygiene.test.ts": 9,
"toolConfig.test.ts": 6,
"trackLive.test.ts": 13,
"typingAfterRead.test.ts": 17,
"themeBridgeLive.test.ts": 12
"viewerLive.test.ts": 13,
"worktreeMutations.test.ts": 11
};

export function discoverTestFiles() {
Expand Down
31 changes: 16 additions & 15 deletions tests/browserEngineChrome.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import test from 'node:test';
import test, {after} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import type {BrowserPage, Frame} from '../src/browser/types.js';
import {chromeSkipReason, createLab, Events, localSite, pidAlive, scratchDir, waitFor} from './support/browserFixtures.js';
import {chromeSkipReason, createLab, disposeSharedLab, Events, localSite, pidAlive, scratchDir, sharedLab, waitFor} from './support/browserFixtures.js';
import {createBrowserEngine} from '../src/browser/engine/index.js';

/**
Expand All @@ -14,6 +14,7 @@ const skip = await chromeSkipReason();
const view = {width: 600, height: 400, scale: 1};
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
const settled = (page: BrowserPage, url?: string): Promise<true> => waitFor(() => (url === undefined || page.url === url) && !page.loading && true, `${url ?? 'the page'} to load`);
after(disposeSharedLab);

test('launch is lazy, uses a private profile, and shutdown leaves no process behind', {skip}, async () => {
const lab = createLab();
Expand Down Expand Up @@ -93,7 +94,7 @@ test('a missing browser is reported in plain words and nothing is started', asyn
});

test('web pages load from a local server; navigation, back, forward and reload work', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
const site = await localSite({'/a': '<title>Page A</title><p>a</p>', '/b': '<title>Page B</title><p>b</p>'});
try {
const page = await lab.engine.open({kind: 'web', viewport: view, url: `${site.origin}/a`});
Expand Down Expand Up @@ -122,7 +123,7 @@ test('web pages load from a local server; navigation, back, forward and reload w
});

test('addresses that are not http, https, file or about:blank are refused and never navigated', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const page = await lab.engine.open({kind: 'web', viewport: view, url: 'about:blank'});
const events = new Events(page);
Expand All @@ -138,7 +139,7 @@ test('addresses that are not http, https, file or about:blank are refused and ne
});

test('a page that redirects or links to a refused scheme ends on about:blank with a blocked event', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
const site = await localSite({'/': '<a id=l href="blob:nope" style="position:absolute;left:0;top:0;width:300px;height:60px;display:block">x</a><title>start</title>'});
try {
const page = await lab.engine.open({kind: 'web', viewport: view, url: site.origin + '/'});
Expand All @@ -158,7 +159,7 @@ test('a page that redirects or links to a refused scheme ends on about:blank wit
const animation = '<style>@keyframes a{from{transform:translateX(0)}to{transform:translateX(300px)}}div{width:80px;height:80px;background:#d33;animation:a 1s linear infinite}</style><div></div>';

test('screencast frames arrive at the viewport size, advance with a CSS animation, and a slow listener skips frames', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview(animation);
const page = await lab.engine.open({kind: 'preview', viewport: {width: 320, height: 200, scale: 1}, url: handle.url});
Expand Down Expand Up @@ -197,7 +198,7 @@ test('screencast frames arrive at the viewport size, advance with a CSS animatio
});

test('zoom keeps the frame size and is clamped', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview('<h1>zoom</h1>');
const page = await lab.engine.open({kind: 'preview', viewport: {width: 300, height: 200, scale: 1}, url: handle.url});
Expand All @@ -224,7 +225,7 @@ const form = `<body style="margin:0"><input id=f style="position:absolute;left:1
<script>f.addEventListener('input',()=>{document.title='v:'+f.value});</script></body>`;

test('mouse and keys reach a trusted page: typing in a field, editable events, and a click on a link', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview(form, {trusted: true, files: {'next.html': '<title>next</title><p>second</p>'}});
const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url, scripts: true});
Expand Down Expand Up @@ -260,7 +261,7 @@ test('mouse and keys reach a trusted page: typing in a field, editable events, a
});

test('wheel events scroll the page', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview('<body style="margin:0;height:3000px"><div style="height:3000px;background:linear-gradient(#fff,#000)"></div>');
const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url});
Expand All @@ -276,7 +277,7 @@ test('wheel events scroll the page', {skip}, async () => {
});

test('find works on a preview with scripts off, steps and wraps, and clears', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview('<p>Needle one</p><p>nothing</p><p>second needle</p><script>document.title="scripts ran"</script><p style="display:none">needle hidden</p><p>NEEDLE last</p>');
const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url});
Expand All @@ -299,7 +300,7 @@ test('find works on a preview with scripts off, steps and wraps, and clears', {s
});

test('setScripts turns page scripts on and off and reloads', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview('<title>before</title><script>document.title="ran"</script>', {trusted: true});
const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url});
Expand All @@ -315,7 +316,7 @@ test('setScripts turns page scripts on and off and reloads', {skip}, async () =>
});

test('popups are closed and reported; dialogs are answered; downloads are refused', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
const site = await localSite({
'/': `<body style="margin:0"><a href="/pop" target="_blank" style="position:absolute;left:0;top:0;width:300px;height:50px;display:block">pop</a>
<a href="/file.bin" style="position:absolute;left:0;top:100px;width:300px;height:50px;display:block">get</a>
Expand Down Expand Up @@ -352,7 +353,7 @@ test('popups are closed and reported; dialogs are answered; downloads are refuse
});

test('a preview refuses remote resources and navigation, and no request leaves the machine', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
const remote = await localSite();
try {
const html = `<body style="margin:0"><img src="${remote.origin}/x.png"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
Expand All @@ -379,7 +380,7 @@ test('a preview refuses remote resources and navigation, and no request leaves t
});

test('previews share nothing with browsing: cookies set in a web page are invisible to a preview', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
const site = await localSite({'/': {type: 'text/html', body: '<title>set</title>', headers: {'Set-Cookie': 'k=v; Path=/'}}});
try {
const web = await lab.engine.open({kind: 'web', viewport: view, url: site.origin + '/'});
Expand All @@ -395,7 +396,7 @@ test('previews share nothing with browsing: cookies set in a web page are invisi
});

test('closing a page closes its target and a preview page disposes its context', {skip}, async () => {
const lab = createLab();
const lab = sharedLab();
try {
const handle = await lab.preview('<p>x</p>');
const page = await lab.engine.open({kind: 'preview', viewport: view, url: handle.url});
Expand Down
19 changes: 16 additions & 3 deletions tests/diagramsChrome.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import test from 'node:test';
import test, {after} from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import type {AddressInfo} from 'node:net';
Expand Down Expand Up @@ -31,12 +31,25 @@ async function skip(): Promise<string | undefined> {
return (await chromeSkipReason()) ?? (await mermaidSkip);
}

async function lab(): Promise<{engine: BrowserEngine; dir: string; done(): Promise<void>}> {
interface DiagramLab {engine: BrowserEngine; dir: string; done(): Promise<void>}

function freshLab(): DiagramLab {
const dir = scratchDir('nmsh-diagrams-');
const engine = createBrowserEngine({profileDir: path.join(dir, 'profile'), idleMs: 60_000});
return {engine, dir, done: async () => { await engine.shutdown(); fs.rmSync(dir, {recursive: true, force: true, maxRetries: 10, retryDelay: 200}); }};
}

// Launching Chromium costs seconds on CI runners: tests that only open renderer pages share one browser; each test closes the
// pages it left open, so the next starts with none. The crash test keeps a browser of its own.
let shared: DiagramLab | undefined;
after(async () => { await shared?.done(); shared = undefined; });

async function lab(options: {fresh?: boolean} = {}): Promise<DiagramLab> {
if (options.fresh) return freshLab();
const base = shared ??= freshLab();
return {...base, done: async () => { await Promise.all(base.engine.pages().map(page => page.close().catch(() => undefined))); }};
}

const request = (source: string, extra: Partial<DiagramRenderRequest> = {}): DiagramRenderRequest => ({source, config: mermaidConfig(THEME), scale: 2, timeoutMs: 10_000, ...extra});

const TYPES: Record<string, string> = {
Expand Down Expand Up @@ -176,7 +189,7 @@ test('a library that never finishes is stopped at the timeout; the page is throw
test('a crashed renderer page is reported, never hangs, and the browser stops cleanly afterwards', async t => {
const reason = await skip();
if (reason) { t.skip(reason); return; }
const {engine, done} = await lab();
const {engine, done} = await lab({fresh: true});
try {
const renderer: DiagramRenderer = await engine.openDiagramRenderer!(library!);
assert.ok((await renderer.render(request(TYPES.flowchart!))).ok);
Expand Down
23 changes: 23 additions & 0 deletions tests/support/browserFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ export function createLab(options: {idleMs?: number} = {}): Lab {
return lab;
}

let shared: Lab | undefined;

/**
* One browser for every test in a file that only needs pages: launching Chromium costs seconds on CI runners, so tests that do
* not exercise launch, idle shutdown or crashes share it. `dispose()` closes the pages the test left open (each test starts with
* none) and keeps the browser; call `disposeSharedLab()` from the file's `after` hook. Lifecycle tests keep `createLab()`.
*/
export function sharedLab(): Lab {
const lab = shared ??= createLab();
return {
...lab,
async dispose() {
await Promise.all(lab.engine.pages().map(page => page.close().catch(() => undefined)));
}
};
}

export async function disposeSharedLab(): Promise<void> {
const lab = shared;
shared = undefined;
await lab?.dispose();
}

/** Collects a page's events and lets a test wait for one. */
export class Events {
readonly all: PageEvent[] = [];
Expand Down
Loading