Add native test-only workflow and durable full regression shards

This commit is contained in:
Forge
2026-09-22 04:35:43 +00:00
parent 13304f935e
commit bae8e16dde
9 changed files with 226 additions and 8 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Extension native validation
# A topic-branch push can run this workflow before it exists on main.
# No release/deployment events, production secrets, or write token permissions.
on:
push:
branches: [feature/extensions-author-kit-20260922]
paths:
- 'src/**'
- '__tests__/**'
- 'scripts/validation/**'
- 'package*.json'
- 'ui/**'
- 'marketplace/**'
- '.github/workflows/extensions-native.yml'
permissions:
contents: read
concurrency:
group: extensions-native-${{ github.ref }}
cancel-in-progress: false
jobs:
native:
# Public repo, standard included runners only (no large/paid labels).
if: github.repository == 'colbymchenry/codegraph'
strategy:
fail-fast: false
max-parallel: 2
matrix:
os: [windows-2022, macos-15]
runs-on: ${{ matrix.os }}
timeout-minutes: 35
env:
CODEGRAPH_TELEMETRY: '0'
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '22.23.2'
- name: Install development dependencies
run: npm ci --no-audit --no-fund
- name: Install test-only browser
run: npm install --prefix .qa/browser --ignore-scripts --no-save --package-lock=false playwright@1.63.0
- name: Download Chromium for this native OS
run: node .qa/browser/node_modules/playwright/cli.js install chromium
- name: Run native acceptance with durable receipts
run: node scripts/validation/native-validation.cjs
- name: Retain validation evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: extensions-native-${{ matrix.os }}-${{ github.sha }}-${{ github.run_attempt }}
path: .qa/native/
include-hidden-files: true
if-no-files-found: warn
retention-days: 7
+2 -1
View File
@@ -10,7 +10,7 @@ const { spawn } = require('node:child_process');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const { startMarketplaceServer } = require('../../dist/plugins/marketplace');
const repo = path.resolve(__dirname, '../..');
const out = path.join(repo, '.qa/recovery/author-e2e');
const out = path.resolve(process.env.AUTHOR_OUTPUT || path.join(repo, '.qa/recovery/author-e2e'));
fs.mkdirSync(out, { recursive: true });
const lab = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-external-author-'));
const author = path.join(lab, 'python-events');
@@ -18,6 +18,7 @@ const project = path.join(lab, 'python-app');
const commands = [];
const env = { ...process.env, npm_config_cache: path.join(lab, 'npm-cache'), CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '2', CODEGRAPH_RESOLVE_WORKERS: '2', CODEGRAPH_PARALLEL_RESOLVE_MIN: '0' };
async function run(command, args, cwd = lab) {
if (command === 'npm') [command, args] = require('./platform-tools.cjs').npmCommand(args);
const start = Date.now();
// Keep the event loop responsive while the CLI downloads from our local registry.
const result = await new Promise(resolve => {
@@ -73,7 +73,7 @@ async function cli(args, expected = 0) {
assert.deepEqual(all.map(r => r.version), ['9.0.0', '1.10.0', '1.9.0', '0.5.0', '20.0.0-beta.1']);
assert.equal(all[0].apiVersion, 1); checks.push('signed immutable versions; semantic catalog order independent of publication order');
bridge = await startExtensionBridge(roots.slice(0, 2), registry);
browser = await chromium.launch({ executablePath: '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] });
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] });
page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(60000);
const errors = []; page.on('pageerror', e => errors.push(e.message));
+7 -5
View File
@@ -1,4 +1,5 @@
// Real SIGKILLs target ONLY children created by this disposable-project harness.
// Forced termination targets ONLY children created by this disposable-project harness.
// Node maps SIGKILL to TerminateProcess on Windows; retain the observed exit/signal.
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
@@ -75,8 +76,9 @@ if (process.argv[2] === '--child') {
try { await Promise.race([c.reached, c.done.then(r => { throw new Error(`Child exited before ${boundary}: ${r.stderr}`); }), new Promise((_, reject) => timer = setTimeout(() => reject(new Error('Boundary timeout: ' + boundary)), 90000))]); }
finally { clearTimeout(timer); }
assert.ok(processes.has(c.processChild));
c.processChild.kill('SIGKILL');
const result = await c.done; assert.equal(result.signal, 'SIGKILL');
assert.equal(c.processChild.kill('SIGKILL'), true);
const result = await c.done;
assert.ok(result.signal === 'SIGKILL' || (process.platform === 'win32' && result.exit !== 0), 'owned paused child must be forcibly terminated');
return c.processChild.pid;
}
async function run(root, action = 'inspect', expected = 0, cli = false) {
@@ -104,7 +106,7 @@ if (process.argv[2] === '--child') {
for (const entry of entries) {
const pkg = path.join(folder, 'packages', entry.integrity);
assert.equal(JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'))).version, entry.version);
assert.equal(trusted[pkg], packageDigest(pkg));
assert.equal(trusted[fs.realpathSync(pkg)], packageDigest(pkg));
}
}
function receipt(value) { rows.push(value); fs.writeFileSync(path.join(out, 'matrix.json'), JSON.stringify(rows, null, 2)); console.log(JSON.stringify(value)); }
@@ -212,7 +214,7 @@ if (process.argv[2] === '--child') {
assert.equal(reader.getNode(node.id).name, '/2.0.0');
} finally { reader.close(); }
receipt({ action: 'long-lived reader after fresh-process recovery', decision: 'old cached node invalidated by commit marker', passed: true });
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, node: process.version, rows, commands: commands.length }, null, 2));
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2));
} finally {
for (const c of processes) c.kill('SIGKILL');
if (processes.size) await new Promise(resolve => setTimeout(resolve, 300));
@@ -15,7 +15,7 @@ process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN = '0';
const roots = [];
after(() => roots.forEach(root => fs.rmSync(root, { recursive: true, force: true })));
function root() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-compiled-'));
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-compiled-')));
roots.push(dir);
fs.writeFileSync(path.join(dir, 'app.ts'), 'export function entry() {}\nexport function handler() {}\n');
return dir;
+38
View File
@@ -0,0 +1,38 @@
// Real native filesystem/casing/separator checks, never a simulated OS switch.
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
const assert = require('node:assert/strict');
const { CodeGraph, createExtensionProject, packExtension } = require('../../dist');
const { ExtensionManager } = require('../../dist/plugins/manager');
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-native-paths-')));
(async () => {
try {
const author = path.join(base, 'Mixed Case Author'), root = path.join(base, 'Mixed Case Python App');
createExtensionProject(author, 'native-paths');
fs.mkdirSync(path.join(root, 'Src'), { recursive: true });
fs.writeFileSync(path.join(root, 'Checkout.events.yaml'), 'order.created: send_receipt\n');
fs.writeFileSync(path.join(root, 'Src', 'Handlers.py'), 'def send_receipt():\n return "sent"\n');
const manager = new ExtensionManager(root);
await manager.install({ bytes: packExtension(author) });
const alias = path.join(base, 'mixed case python app');
const caseInsensitive = fs.existsSync(alias);
const chosen = caseInsensitive ? alias : root;
const nativeSeparated = path.normalize(chosen);
let graph = await CodeGraph.open(nativeSeparated);
try {
const routes = graph.getNodesByKind('route'); assert.equal(routes.length, 1);
const edges = graph.getOutgoingEdges(routes[0].id).filter(e => e.metadata?.synthesizedBy === 'native-paths');
assert.equal(edges.length, 1); const handler = graph.getNode(edges[0].target);
assert.equal(handler.name, 'send_receipt');
assert.equal(path.basename(handler.filePath), 'Handlers.py');
assert.ok(fs.existsSync(path.resolve(root, handler.filePath)));
} finally { graph.close(); }
const aliasManager = new ExtensionManager(nativeSeparated);
await aliasManager.setEnabled('native-paths', false);
graph = await CodeGraph.open(root); try { assert.deepEqual(graph.getNodesByKind('route'), []); } finally { graph.close(); }
await aliasManager.setEnabled('native-paths', true);
await aliasManager.remove('native-paths');
graph = await CodeGraph.open(root); try { assert.deepEqual(graph.getNodesByKind('route'), []); } finally { graph.close(); }
console.log(JSON.stringify({ platform: process.platform, arch: process.arch, separator: path.sep, caseInsensitive,
aliasExercised: caseInsensitive, checks: ['mixed case and spaced project paths', 'nested native file separators', 'actual event-to-handler graph edge', 'case alias when supported by real filesystem', 'disable/enable/remove through selected path'] }));
} finally { fs.rmSync(base, { recursive: true, force: true }); }
})().catch(error => { console.error(error.stack); process.exitCode = 1; });
+57
View File
@@ -0,0 +1,57 @@
// Test-only native runner: durable receipts, real OS metadata, no release commands.
const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
const { spawn, execFileSync } = require('node:child_process');
const { createHash } = require('node:crypto');
const { npmCommand } = require('./platform-tools.cjs');
const root = path.resolve(__dirname, '../..');
const out = path.resolve(process.env.NATIVE_OUTPUT || `.qa/native/${process.platform}-${process.arch}`);
fs.mkdirSync(out, { recursive: true });
const version = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
const metadata = { revision: version, platform: process.platform, arch: process.arch, release: os.release(),
node: process.version, runnerOS: process.env.RUNNER_OS, runnerArch: process.env.RUNNER_ARCH,
imageOS: process.env.ImageOS, imageVersion: process.env.ImageVersion,
githubRun: process.env.GITHUB_RUN_ID, githubAttempt: process.env.GITHUB_RUN_ATTEMPT,
started: new Date().toISOString(), steps: [] };
function save() { fs.writeFileSync(path.join(out, 'summary.json.tmp'), JSON.stringify(metadata, null, 2)); fs.renameSync(path.join(out, 'summary.json.tmp'), path.join(out, 'summary.json')); }
save();
const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_WASM_RELAUNCHED: '1', CODEGRAPH_PARSE_WORKERS: '2', CODEGRAPH_RESOLVE_WORKERS: '2', CODEGRAPH_PARALLEL_RESOLVE_MIN: '0',
RECOVERY_OUTPUT: path.join(out, 'recovery'), AUTHOR_OUTPUT: path.join(out, 'author'), COMPAT_OUTPUT: path.join(out, 'compatibility'), BROWSER_OUTPUT: path.join(out, 'browser') };
const playwright = process.env.PLAYWRIGHT_MODULE || path.join(root, '.qa/browser/node_modules/playwright');
env.PLAYWRIGHT_MODULE = playwright;
env.CHROME_PATH = process.env.CHROME_PATH || require(playwright).chromium.executablePath();
async function run(name, command, args, timeout = 360000) {
const step = { name, command: [command, ...args], cwd: root, revision: version, started: new Date().toISOString(), status: 'running' };
metadata.steps.push(step); save(); console.log('START', name);
const logFile = path.join(out, name + '.log'); const log = fs.openSync(logFile, 'w');
const started = Date.now();
const result = await new Promise(resolve => {
const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', log, log], timeout });
let error;
child.on('error', e => error = String(e));
child.on('close', (exit, signal) => resolve({ exit, signal, error }));
});
fs.fsyncSync(log); fs.closeSync(log);
Object.assign(step, result, { status: result.exit === 0 ? 'passed' : 'failed', seconds: (Date.now() - started) / 1000,
finished: new Date().toISOString(), log: name + '.log', sha256: createHash('sha256').update(fs.readFileSync(logFile)).digest('hex') });
save(); console.log('END', name, step.status, step.seconds);
if (step.status === 'failed') console.log(fs.readFileSync(logFile, 'utf8').split('\n').slice(-65).join('\n'));
return step.status === 'passed';
}
(async () => {
const built = await run('build', ...npmCommand(['run', 'build']));
if (built) {
await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace']));
await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run',
'__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',
'--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]);
await run('compiled-workers', process.execPath, ['--test', 'scripts/validation/extensions-runtime.test.cjs']);
await run('native-paths', process.execPath, ['scripts/validation/native-paths.cjs']);
await run('process-recovery', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000);
await run('external-author', process.execPath, ['scripts/validation/extension-author.cjs'], 600000);
await run('compatibility', process.execPath, ['scripts/validation/extensions-compatibility.cjs']);
await run('browser', process.execPath, ['scripts/validation/marketplace-browser.cjs']);
} else metadata.blocked = 'Build failed; dependent checks were not executed';
metadata.finished = new Date().toISOString(); metadata.success = built && metadata.steps.every(s => s.status === 'passed'); save();
process.exitCode = metadata.success ? 0 : 1;
})().catch(error => { metadata.failure = String(error.stack); save(); console.error(error); process.exitCode = 1; });
+14
View File
@@ -0,0 +1,14 @@
// Validation-only helpers. Invoke npm's JS CLI directly (npm.cmd is not an
// executable on Windows), without shell interpolation of project paths.
const fs = require('node:fs');
const path = require('node:path');
function npmCommand(args) {
const bin = path.dirname(process.execPath);
const candidates = [process.env.npm_execpath, process.env.NPM_CLI,
path.join(bin, 'node_modules/npm/bin/npm-cli.js'),
path.resolve(bin, '../lib/node_modules/npm/bin/npm-cli.js')];
const cli = candidates.find(p => p && p.endsWith('.js') && fs.existsSync(p));
if (!cli) throw new Error('Cannot locate npm-cli.js. Set NPM_CLI to the installed npm JavaScript entry point.');
return [process.execPath, [cli, ...args]];
}
module.exports = { npmCommand };
+46
View File
@@ -0,0 +1,46 @@
// Four sequential full-suite shards, resumable only with matching receipts.
const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
const { spawn, execFileSync } = require('node:child_process');
const { createHash } = require('node:crypto');
const out = path.resolve(process.env.REGRESSION_OUTPUT || '.qa/native-linux'); fs.mkdirSync(out, { recursive: true });
const revision = () => execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const source = revision(), results = [];
const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
function write(file, data) { fs.writeFileSync(file + '.tmp', JSON.stringify(data, null, 2)); fs.renameSync(file + '.tmp', file); }
function expectedFiles(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap(d => d.isDirectory() ? expectedFiles(path.join(dir, d.name)) : d.name.endsWith('.test.ts') ? [path.resolve(dir, d.name)] : []); }
(async () => {
for (let shard = 1; shard <= 4; shard++) {
if (revision() !== source) throw new Error('HEAD changed during shards; completed receipts retained; refuse a mixed-revision total');
const stem = path.join(out, `shard-${shard}`), report = stem + '.tests.json', file = stem + '.json', logFile = stem + '.log';
if (process.argv.includes('--resume') && fs.existsSync(file)) {
const saved = JSON.parse(fs.readFileSync(file));
if (saved.revision === source && saved.exit === 0 && digest(report) === saved.reportHash && digest(logFile) === saved.logHash) {
results.push(saved); console.log('REUSE', shard); continue;
}
throw new Error('Existing receipt is not a verified successful shard of this revision; choose a new REGRESSION_OUTPUT');
}
if (fs.existsSync(file) || fs.existsSync(stem + '.started.json')) throw new Error('Prior attempt exists; inspect it and select a new output directory rather than overwrite evidence');
const args = ['node_modules/vitest/vitest.mjs', 'run', `--shard=${shard}/4`, '--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${report}`];
const receipt = { revision: source, shard, command: [process.execPath, ...args], platform: process.platform, arch: process.arch, node: process.version, release: os.release(), started: new Date().toISOString() };
write(stem + '.started.json', receipt); const started = Date.now(), fd = fs.openSync(logFile, 'w'); console.log('START', shard);
const status = await new Promise(resolve => {
const child = spawn(process.execPath, args, { stdio: ['ignore', fd, fd], timeout: 600000 });
let error; child.on('error', e => error = String(e)); child.on('close', (exit, signal) => resolve({ exit, signal, error }));
});
fs.fsyncSync(fd); fs.closeSync(fd);
Object.assign(receipt, status, { seconds: (Date.now() - started) / 1000, finished: new Date().toISOString(), logHash: digest(logFile) });
if (fs.existsSync(report)) {
const r = JSON.parse(fs.readFileSync(report));
Object.assign(receipt, { reportHash: digest(report), passed: r.numPassedTests, failed: r.numFailedTests, skipped: r.numPendingTests, files: r.testResults.map(f => f.name) });
} else receipt.incomplete = 'Vitest JSON result missing';
write(file, receipt); results.push(receipt); write(path.join(out, 'checkpoint.json'), { revision: source, results });
console.log('END', shard, JSON.stringify(receipt));
}
const expected = expectedFiles('__tests__').sort(), actual = results.flatMap(r => r.files || []).sort();
const counts = actual.reduce((m, f) => (m[f] = (m[f] || 0) + 1, m), {});
const missing = expected.filter(f => !counts[f]), duplicate = Object.keys(counts).filter(f => counts[f] !== 1), unexpected = actual.filter(f => !expected.includes(f));
const aggregate = { revision: source, results, expectedFiles: expected.length, observedFiles: actual.length, missing, duplicate, unexpected,
passed: results.reduce((n, r) => n + (r.passed || 0), 0), failed: results.reduce((n, r) => n + (r.failed || 0), 0), skipped: results.reduce((n, r) => n + (r.skipped || 0), 0) };
aggregate.success = results.length === 4 && results.every(r => r.exit === 0 && !r.incomplete) && !missing.length && !duplicate.length && !unexpected.length;
write(path.join(out, 'result.json'), aggregate); console.log(JSON.stringify(aggregate)); process.exitCode = aggregate.success ? 0 : 1;
})().catch(error => { write(path.join(out, 'incomplete.json'), { revision: source, error: String(error.stack), results }); console.error(error); process.exitCode = 1; });