From bae8e16dde713c6c99e8ed55772d56980e6855ce Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 04:35:43 +0000 Subject: [PATCH] Add native test-only workflow and durable full regression shards --- .github/workflows/extensions-native.yml | 60 +++++++++++++++++++ scripts/validation/extension-author.cjs | 3 +- .../validation/extensions-compatibility.cjs | 2 +- scripts/validation/extensions-recovery.cjs | 12 ++-- .../validation/extensions-runtime.test.cjs | 2 +- scripts/validation/native-paths.cjs | 38 ++++++++++++ scripts/validation/native-validation.cjs | 57 ++++++++++++++++++ scripts/validation/platform-tools.cjs | 14 +++++ scripts/validation/regression-shards.cjs | 46 ++++++++++++++ 9 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/extensions-native.yml create mode 100644 scripts/validation/native-paths.cjs create mode 100644 scripts/validation/native-validation.cjs create mode 100644 scripts/validation/platform-tools.cjs create mode 100644 scripts/validation/regression-shards.cjs diff --git a/.github/workflows/extensions-native.yml b/.github/workflows/extensions-native.yml new file mode 100644 index 000000000..2f51c53f1 --- /dev/null +++ b/.github/workflows/extensions-native.yml @@ -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 diff --git a/scripts/validation/extension-author.cjs b/scripts/validation/extension-author.cjs index 7eb8d3735..bffa56cd2 100644 --- a/scripts/validation/extension-author.cjs +++ b/scripts/validation/extension-author.cjs @@ -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 => { diff --git a/scripts/validation/extensions-compatibility.cjs b/scripts/validation/extensions-compatibility.cjs index 35ddae2f5..3baada63f 100644 --- a/scripts/validation/extensions-compatibility.cjs +++ b/scripts/validation/extensions-compatibility.cjs @@ -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)); diff --git a/scripts/validation/extensions-recovery.cjs b/scripts/validation/extensions-recovery.cjs index 81625d5a8..1f7575d3b 100644 --- a/scripts/validation/extensions-recovery.cjs +++ b/scripts/validation/extensions-recovery.cjs @@ -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)); diff --git a/scripts/validation/extensions-runtime.test.cjs b/scripts/validation/extensions-runtime.test.cjs index 139d9b048..2ed6225b9 100644 --- a/scripts/validation/extensions-runtime.test.cjs +++ b/scripts/validation/extensions-runtime.test.cjs @@ -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; diff --git a/scripts/validation/native-paths.cjs b/scripts/validation/native-paths.cjs new file mode 100644 index 000000000..be8420386 --- /dev/null +++ b/scripts/validation/native-paths.cjs @@ -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; }); diff --git a/scripts/validation/native-validation.cjs b/scripts/validation/native-validation.cjs new file mode 100644 index 000000000..e36ac0517 --- /dev/null +++ b/scripts/validation/native-validation.cjs @@ -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; }); diff --git a/scripts/validation/platform-tools.cjs b/scripts/validation/platform-tools.cjs new file mode 100644 index 000000000..13e20dcc9 --- /dev/null +++ b/scripts/validation/platform-tools.cjs @@ -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 }; diff --git a/scripts/validation/regression-shards.cjs b/scripts/validation/regression-shards.cjs new file mode 100644 index 000000000..affa3a1c7 --- /dev/null +++ b/scripts/validation/regression-shards.cjs @@ -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; });