Recover project extensions and validate worker and marketplace lifecycle

This commit is contained in:
Forge
2026-09-22 01:29:58 +00:00
parent ba3c21e50d
commit c33354c930
49 changed files with 2358 additions and 53 deletions
+8
View File
@@ -0,0 +1,8 @@
import { build } from 'esbuild';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
await build({ entryPoints: ['extensions/drupal/index.cjs'], bundle: true, platform: 'node', target: 'node22', format: 'cjs', outfile: 'extensions/drupal/dist/index.cjs' });
const pkg = JSON.parse(readFileSync('extensions/drupal/package.json', 'utf8'));
const artifact = { format: 'codegraph-extension-1', package: pkg, files: { 'dist/index.cjs': readFileSync('extensions/drupal/dist/index.cjs', 'utf8') } };
mkdirSync('dist/extensions', { recursive: true });
writeFileSync('dist/extensions/drupal.cgext', JSON.stringify(artifact));
console.log('Built official Drupal extension');
+31
View File
@@ -0,0 +1,31 @@
// node scripts/validation/control-benchmark.cjs <engine checkout> <fresh control checkout> <result.json>
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const crypto = require('node:crypto');
const { DatabaseSync } = require('node:sqlite');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
process.env.CODEGRAPH_RESOLVE_WORKERS = '0';
const [engine, corpus, output] = process.argv.slice(2).map(p => path.resolve(p));
const { CodeGraph } = require(path.join(engine, 'dist'));
(async () => {
const started = performance.now();
const graph = await CodeGraph.init(corpus);
let result;
try { result = await graph.indexAll(); } finally { graph.close(); }
if (!result.success || result.filesErrored) throw Error(JSON.stringify(result.errors));
const ms = Math.round(performance.now() - started);
const db = new DatabaseSync(path.join(corpus, '.codegraph/codegraph.db'), { readOnly: true });
let nodes, edges;
try {
nodes = db.prepare('SELECT * FROM nodes ORDER BY id').all().map(({ updated_at, ...n }) => n);
edges = db.prepare('SELECT source,target,kind,metadata,line,col,provenance FROM edges ORDER BY source,target,kind,line,col,metadata').all();
} finally { db.close(); }
const record = { engine, revision: cp.execFileSync('git', ['-C', engine, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
corpus, corpusRevision: cp.execFileSync('git', ['-C', corpus, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
ms, files: result.filesIndexed, nodes: nodes.length, edges: edges.length,
hash: crypto.createHash('sha256').update(JSON.stringify({ nodes, edges })).digest('hex') };
fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(record, null, 2));
console.log(JSON.stringify(record));
})().catch(error => { console.error(error); process.exitCode = 1; });
+140
View File
@@ -0,0 +1,140 @@
// Uses caller-owned corpus checkouts. Never run against a user's working repository.
// Build engine/extension first, then: node scripts/validation/drupal-corpus.cjs ../validation
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const cp = require('node:child_process');
const { DatabaseSync } = require('node:sqlite');
const { CodeGraph } = require('../../dist');
const { ToolHandler } = require('../../dist/mcp/tools');
const { ExtensionManager } = require('../../dist/plugins/manager');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
// Keep the large-repository run within a 4 GiB host. Compiled resolver workers
// are exercised separately by extensions-runtime.test.cjs.
process.env.CODEGRAPH_RESOLVE_WORKERS = '0';
const corpus = path.resolve(process.argv[2]);
const out = path.resolve(process.env.CORPUS_OUTPUT || '.qa/recovery/corpus'); fs.mkdirSync(out, { recursive: true });
const bytes = fs.readFileSync('dist/extensions/drupal.cgext');
const artifactSHA = crypto.createHash('sha256').update(bytes).digest('hex');
const definitions = {
pathauto: {
flows: [
['/admin/config/search/path/settings', 'pathauto.routing.yml', 'buildForm', 'src/Form/PathautoSettingsForm.php', 'Drupal route handler', '/admin/config/search/path/settings PathautoSettingsForm.buildForm'],
['/admin/config/search/path/update_bulk', 'pathauto.routing.yml', 'buildForm', 'src/Form/PathautoBulkUpdateForm.php', 'Drupal route handler', '/admin/config/search/path/update_bulk PathautoBulkUpdateForm.buildForm'],
['getPunctuationCharacters', 'src/AliasCleaner.php', 'pathautoPunctuationCharsAlter', 'tests/modules/pathauto_custom_punctuation_test/src/Hook/PathautoCustomPunctuationTestHooks.php', 'Drupal hook pathauto_punctuation_chars_alter', 'AliasCleaner.getPunctuationCharacters PathautoCustomPunctuationTestHooks.pathautoPunctuationCharsAlter'],
],
extra: [['PathautoGenerator', 'src/PathautoGenerator.php', 'AliasCleaner', 'src/AliasCleaner.php', 'Drupal service injection']],
},
commerce: {
flows: [
['/cart', 'modules/cart/commerce_cart.routing.yml', 'cartPage', 'modules/cart/src/Controller/CartController.php', 'Drupal route handler', '/cart CartController.cartPage'],
['/checkout/{commerce_order}/{step}', 'modules/checkout/commerce_checkout.routing.yml', 'formPage', 'modules/checkout/src/Controller/CheckoutController.php', 'Drupal route handler', '/checkout/{commerce_order}/{step} CheckoutController.formPage'],
['/admin/commerce/orders/add', 'modules/order/commerce_order.routing.yml', 'buildForm', 'modules/order/src/Form/OrderAddForm.php', 'Drupal route handler', '/admin/commerce/orders/add OrderAddForm.buildForm'],
],
extra: [
['CurrentCountry', 'src/CurrentCountry.php', 'ChainCountryResolver', 'src/Resolver/ChainCountryResolver.php', 'Drupal service injection'],
['Block:commerce_cart', 'modules/cart/src/Plugin/Block/CartBlock.php', 'CartBlock', 'modules/cart/src/Plugin/Block/CartBlock.php', 'Drupal plugin implementation'],
],
},
drupal: {
flows: [
['setAccount', 'core/lib/Drupal/Core/Session/AccountProxy.php', 'setDefaultTimeZone', 'core/modules/system/src/TimeZoneResolver.php', 'Drupal event account.set', 'AccountProxy.setAccount TimeZoneResolver.setDefaultTimeZone'],
['filterResponse', 'core/modules/big_pipe/src/Render/BigPipe.php', 'onResponse', 'core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php', 'Drupal event Symfony\\Component\\HttpKernel\\KernelEvents::RESPONSE', 'BigPipe.filterResponse ActiveLinkResponseFilter.onResponse'],
['finish', 'core/lib/Drupal/Core/Config/ConfigImporter.php', 'onConfigImporterImport', 'core/lib/Drupal/Core/EventSubscriber/ConfigSnapshotSubscriber.php', 'Drupal event config.importer.import', 'ConfigImporter.finish ConfigSnapshotSubscriber.onConfigImporterImport'],
], extra: [],
},
};
function snapshot(root) {
const db = new DatabaseSync(path.join(root, '.codegraph/codegraph.db'), { readOnly: true });
try {
const nodes = db.prepare('SELECT * FROM nodes ORDER BY id').all().map(({ updated_at, ...n }) => n);
const edges = db.prepare('SELECT source,target,kind,metadata,line,col,provenance FROM edges ORDER BY source,target,kind,line,col,metadata').all();
const pluginEdges = edges.filter(e => JSON.parse(e.metadata || '{}').synthesizedBy === 'drupal');
return { hash: crypto.createHash('sha256').update(JSON.stringify({ nodes, edges })).digest('hex'),
files: db.prepare('SELECT count(*) AS n FROM files').get().n, nodes, edges, pluginEdges };
} finally { db.close(); }
}
async function timed(record, label, fn) {
const start = performance.now();
console.log(record.repo, label, 'started');
const value = await fn();
record.timings[label] = Math.round(performance.now() - start);
console.log(record.repo, label, record.timings[label] + 'ms');
if (value && Object.hasOwn(value, 'success')) assert.ok(value.success && value.filesErrored === 0, JSON.stringify(value.errors));
return value;
}
async function graphOperation(root, fn) {
const graph = CodeGraph.isInitialized(root) ? await CodeGraph.open(root) : await CodeGraph.init(root);
try { return await fn(graph); } finally { graph.close(); }
}
function edgeCheck(data, definition) {
const [from, sourceFile, to, targetFile, label] = definition;
const sources = new Set(data.nodes.filter(n => n.name === from && n.file_path === sourceFile).map(n => n.id));
const targets = new Set(data.nodes.filter(n => n.name === to && n.file_path === targetFile).map(n => n.id));
assert.ok(sources.size && targets.size, 'Expected symbols: ' + JSON.stringify(definition));
assert.ok(data.pluginEdges.some(e => sources.has(e.source) && targets.has(e.target) && JSON.parse(e.metadata).label === label), 'Missing edge: ' + JSON.stringify(definition));
}
(async () => {
const results = [];
for (const [repo, definition] of Object.entries(definitions)) {
const root = path.join(corpus, repo);
const record = { repo, root, revision: cp.execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), artifactSHA, timings: {}, checks: [], errors: [] };
results.push(record);
try {
// CLI-controlled corpus contains no pre-existing plugin config on its first run.
assert.ok(!new ExtensionManager(root).list().length, 'Start from a fresh corpus checkout');
await timed(record, 'builtin_fresh', () => graphOperation(root, g => g.indexAll()));
await timed(record, 'builtin_rebuild', () => graphOperation(root, g => g.refreshPluginIndex()));
let builtin = snapshot(root); record.builtin = { nodes: builtin.nodes.length, edges: builtin.edges.length, files: builtin.files };
builtin = null;
await timed(record, 'extension_install', () => new ExtensionManager(root).install({ bytes, replaces: ['drupal'] }));
let initial = snapshot(root);
record.extension = { files: initial.files, nodes: initial.nodes.length, edges: initial.edges.length, pluginEdges: initial.pluginEdges.length, hash: initial.hash };
for (const expected of [...definition.flows, ...definition.extra]) edgeCheck(initial, expected);
record.checks.push('explicit route/hook/event/DI/plugin edge assertions');
assert.ok(initial.nodes.filter(n => n.kind === 'route' && n.file_path.endsWith('.routing.yml')).every(n => n.id.startsWith('plugin:drupal:')));
const initialHash = initial.hash;
initial = null;
record.checks.push('built-in Drupal routing contributions replaced');
record.probes = [];
await graphOperation(root, async graph => {
for (let i = 0; i < definition.flows.length; i++) {
const flow = definition.flows[i], start = performance.now();
const response = await new ToolHandler(graph).execute('codegraph_explore', { query: flow[5] });
const text = response.content?.map(c => c.text || '').join('\n') || '';
fs.writeFileSync(path.join(out, repo + '-flow-' + (i + 1) + '.txt'), text);
const flowSection = text.split('**Exploration:')[0];
record.probes.push({ query: flow[5], ms: Math.round(performance.now() - start), chars: text.length,
hasFlow: /Flow|Dynamic-dispatch links/.test(flowSection), containsLabel: text.includes(flow[4]), isError: response.isError === true,
flowSection: flowSection.slice(0, 4000) });
assert.ok(!response.isError && text.includes(flow[4]), 'Explore did not surface expected flow: ' + flow[5]);
}
});
record.checks.push('three deterministic explore probes');
await timed(record, 'extension_rebuild', () => graphOperation(root, g => g.refreshPluginIndex()));
assert.equal(snapshot(root).hash, initialHash, 'Deterministic clean rebuild');
record.checks.push('full graph deterministic clean rebuild');
const probe = path.join(root, 'recovery_probe.routing.yml');
fs.writeFileSync(probe, "recovery.route:\n path: '/__recovery_probe'\n defaults:\n _controller: '\\Missing\\Unknown::missing'\n");
await timed(record, 'incremental_add', () => graphOperation(root, g => g.sync({ paths: ['recovery_probe.routing.yml'] })));
let incremental = snapshot(root);
const route = incremental.nodes.find(n => n.name === '/__recovery_probe');
assert.ok(route);
assert.ok(!incremental.pluginEdges.some(e => e.source === route.id), 'Unknown controller stays unresolved');
const incrementalHash = incremental.hash;
incremental = null;
await timed(record, 'incremental_clean_compare', () => graphOperation(root, g => g.refreshPluginIndex()));
assert.equal(snapshot(root).hash, incrementalHash, 'Incremental add converges to full rebuild');
fs.unlinkSync(probe);
await timed(record, 'incremental_remove', () => graphOperation(root, g => g.sync({ paths: ['recovery_probe.routing.yml'] })));
assert.equal(snapshot(root).hash, initialHash, 'Incremental removal restores original graph');
record.checks.push('incremental add/remove convergence and unknown-controller negative');
} catch (error) { record.errors.push(String(error.stack || error)); console.error(repo, error); }
fs.writeFileSync(path.join(out, repo + '.json'), JSON.stringify(record, null, 2));
}
fs.writeFileSync(path.join(out, 'summary.json'), JSON.stringify(results, null, 2));
console.log(JSON.stringify(results.map(({ repo, checks, errors, timings }) => ({ repo, checks, errors, timings })), null, 2));
process.exitCode = results.some(r => r.errors.length) ? 1 : 0;
})().catch(error => { console.error(error); process.exitCode = 1; });
+107
View File
@@ -0,0 +1,107 @@
const { test } = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const assert = require('node:assert/strict');
const { DatabaseSync } = require('node:sqlite');
const { ExtensionManager } = require('../../dist/plugins/manager');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
test('Drupal explicit flows resolve; comments, strings, computed ids, cycles and ambiguous plugin ids stay unresolved', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-negative-'));
try {
const files = {
'composer.json': JSON.stringify({ name: 'drupal/recovery' }),
'sample.routing.yml': `sample.valid:
path: '/sample/{id}'
defaults:
_controller: '\\Drupal\\sample\\Handler::run'
sample.unknown:
path: '/unknown'
defaults:
_controller: '\\Missing\\Controller::missing'
`,
'sample.services.yml': `services:
sample.handler:
class: Drupal\\sample\\Handler
arguments: ['@sample.dep', '@missing', '@cycle.a']
sample.dep:
class: Drupal\\sample\\Dependency
cycle.a:
alias: cycle.b
cycle.b:
alias: cycle.a
`,
'Handler.php': `<?php
namespace Drupal\\sample;
class Handler {
public function run() {
\\Drupal::service('sample.dep');
$manager->createInstance('valid');
$manager->createInstance('duplicate');
$dispatcher->dispatch($event, 'sample.done');
$modules->invokeAll('sample');
}
public function noise() {
// \\Drupal::service('sample.dep');
$example = "\\Drupal::service('sample.dep')";
/* $modules->invokeAll('sample'); */
$computed = 'sample.dep';
\\Drupal::service($computed);
$manager->createInstance($plugin);
}
}
class Dependency {}
`,
'sample.module': `<?php
/** Implements hook_sample(). */
function sample_sample() {}
`,
'Hooks.php': `<?php
namespace Drupal\\sample;
class Hooks {
#[\\Drupal\\Core\\Hook\\Attribute\\Hook('sample')]
public function sampleHook() {}
}
`,
'Subscriber.php': `<?php
namespace Drupal\\sample;
use Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;
class Subscriber implements EventSubscriberInterface {
public static function getSubscribedEvents() { return ['sample.done' => 'onDone']; }
public function onDone() {}
}
`,
'Valid.php': `<?php
namespace Drupal\\sample;
#[\\Drupal\\Core\\Block\\Attribute\\Block(id: 'valid')]
class Valid {}
`,
'DuplicateOne.php': `<?php
namespace Drupal\\sample;
/** @Block(id = "duplicate") */
class DuplicateOne {}
`,
'DuplicateTwo.php': `<?php
namespace Drupal\\sample;
/** @Block(id = "duplicate") */
class DuplicateTwo {}
`,
};
for (const [file, source] of Object.entries(files)) fs.writeFileSync(path.join(root, file), source);
await new ExtensionManager(root).install({ bytes: fs.readFileSync('dist/extensions/drupal.cgext'), replaces: ['drupal'] });
const db = new DatabaseSync(path.join(root, '.codegraph/codegraph.db'), { readOnly: true });
try {
const edges = db.prepare(`SELECT s.name AS source,t.name AS target,json_extract(e.metadata,'$.label') AS label
FROM edges e JOIN nodes s ON e.source=s.id JOIN nodes t ON e.target=t.id WHERE json_extract(e.metadata,'$.synthesizedBy')='drupal'`).all();
for (const [source, target, label] of [
['/sample/{id}', 'run', 'Drupal route handler'], ['Handler', 'Dependency', 'Drupal service injection'],
['run', 'Dependency', 'Drupal service lookup'], ['run', 'Valid', 'Drupal plugin construction'],
['run', 'onDone', 'Drupal event sample.done'], ['run', 'sample_sample', 'Drupal hook sample'], ['run', 'sampleHook', 'Drupal hook sample'],
]) assert.ok(edges.some(e => e.source === source && e.target === target && e.label === label), JSON.stringify({ source, target, label, edges }));
assert.deepEqual(edges.filter(e => e.source === 'noise'), [], 'Non-code and computed lookups are not graph evidence');
assert.deepEqual(edges.filter(e => e.source === '/unknown'), []);
assert.ok(!edges.some(e => e.label === 'Drupal plugin construction' && e.target.startsWith('Duplicate')));
} finally { db.close(); }
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
@@ -0,0 +1,84 @@
// Run after npm run build: node --test scripts/validation/extensions-runtime.test.cjs
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { CodeGraph } = require('../../dist');
const { ExtensionManager } = require('../../dist/plugins/manager');
const { clearProjectConfigCache } = require('../../dist/project-config');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
process.env.CODEGRAPH_RESOLVE_WORKERS = '2';
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-'));
roots.push(dir);
fs.writeFileSync(path.join(dir, 'app.ts'), 'export function entry() {}\nexport function handler() {}\n');
return dir;
}
function bytes(version = '1.0.0', failure = '') {
return Buffer.from(JSON.stringify({ format: 'codegraph-extension-1', package: {
name: '@test/compiled', version, main: 'index.cjs',
codegraph: { id: 'compiled', apiVersion: 1, capabilities: ['frameworks', 'synthPasses'] },
}, files: { 'index.cjs': `const {threadId}=require('node:worker_threads'); module.exports = ctx => ({
frameworks:[{name:'routes',languages:['typescript'],detect:()=>true,resolve:()=>null,
extract(file){if(${JSON.stringify(failure)}==='parse' && threadId>0)throw Error('parse worker rejection');
return {nodes:[{id:'plugin:compiled:'+file,kind:'route',name:ctx.options.route||'/initial',
qualifiedName:file+'::route',filePath:file,language:'typescript',startLine:1,endLine:1,startColumn:0,endColumn:0,
updatedAt:0,docstring:JSON.stringify({threadId,root:ctx.projectRoot})}],references:[]};}}],
synthPasses:[{name:'flow',languages:['typescript'],async run(graph){
if(${JSON.stringify(failure)}==='resolver' && threadId>0)throw Error('resolver worker rejection');
const a=graph.getNodesByName('entry')[0],b=graph.getNodesByName('handler')[0];
return a&&b?[{source:a.id,target:b.id,kind:'calls',metadata:{label:'Compiled test',threadId,root:ctx.projectRoot}}]:[];
}}]});` }}));
}
function inspect(dir) {
const db = new DatabaseSync(path.join(dir, '.codegraph/codegraph.db'), { readOnly: true });
try {
const nodes = db.prepare('SELECT * FROM nodes ORDER BY id').all().map(({ updated_at, ...n }) => n);
const edges = db.prepare('SELECT source,target,kind,metadata,line,col,provenance FROM edges ORDER BY source,target,kind,line,col,metadata').all();
return { nodes, edges, routes: nodes.filter(n => n.kind === 'route'), pluginEdges: edges.filter(e => JSON.parse(e.metadata || '{}').synthesizedBy === 'compiled') };
} finally { db.close(); }
}
test('compiled parse and resolver workers activate, isolate root/options, and clean lifecycle contributions', async () => {
const a = root(), b = root();
const manager = new ExtensionManager(a);
await manager.install({ bytes: bytes() });
let data = inspect(a);
assert.equal(data.routes.length, 1);
assert.ok(JSON.parse(data.routes[0].docstring).threadId > 0, 'parse contribution ran in a worker');
assert.ok(JSON.parse(data.pluginEdges[0].metadata).threadId > 0, 'synthesis ran in a resolver worker');
const config = JSON.parse(fs.readFileSync(path.join(a, 'codegraph.json')));
config.plugins[0].options = { route: '/changed' };
fs.writeFileSync(path.join(a, 'codegraph.json'), JSON.stringify(config));
clearProjectConfigCache();
let graph = await CodeGraph.open(a);
try { await graph.sync(); } finally { graph.close(); }
assert.deepEqual(inspect(a).routes.map(n => n.name), ['/changed']);
await new ExtensionManager(b).install({ bytes: bytes() });
assert.deepEqual(inspect(b).routes.map(n => n.name), ['/initial']);
assert.equal(JSON.parse(inspect(b).routes[0].docstring).root, b);
assert.equal(JSON.parse(inspect(a).routes[0].docstring).root, a);
await manager.setEnabled('compiled', false);
assert.equal(inspect(a).routes.length, 0); assert.equal(inspect(a).pluginEdges.length, 0);
await manager.setEnabled('compiled', true);
assert.deepEqual(inspect(a).routes.map(n => n.name), ['/changed']);
await manager.remove('compiled');
assert.equal(inspect(a).routes.length, 0); assert.equal(inspect(a).pluginEdges.length, 0);
assert.equal(inspect(b).routes.length, 1);
});
test('failed parse/resolver worker updates preserve the prior config and entire graph', async () => {
const dir = root(), manager = new ExtensionManager(dir);
await manager.install({ bytes: bytes() });
const before = inspect(dir), config = fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf8');
for (const stage of ['parse', 'resolver']) {
await assert.rejects(manager.install({ bytes: bytes('2.0.0', stage) }), /activation failed/);
assert.equal(fs.readFileSync(path.join(dir, 'codegraph.json'), 'utf8'), config);
assert.deepEqual(inspect(dir), before);
}
assert.ok(!fs.readdirSync(path.join(dir, '.codegraph')).some(name => name.startsWith('extension-stage-')));
});
+177
View File
@@ -0,0 +1,177 @@
// PLAYWRIGHT_MODULE=/absolute/path/to/playwright node scripts/validation/marketplace-browser.cjs
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const assert = require('node:assert/strict');
const { DatabaseSync } = require('node:sqlite');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const { startMarketplaceServer } = require('../../dist/plugins/marketplace');
const { startExtensionBridge } = require('../../dist/plugins/bridge');
process.env.CODEGRAPH_TELEMETRY = '0';
process.env.CODEGRAPH_PARSE_WORKERS = '2';
const out = path.resolve('.qa/recovery/browser'); fs.mkdirSync(out, { recursive: true });
const checks = [];
const safeError = error => String(error.stack || error).replace(/token=[a-f0-9]+/g, 'token=[redacted]');
function artifact(version, broken = false) {
return Buffer.from(JSON.stringify({ format: 'codegraph-extension-1', package: {
name: '@browser/example', version, main: 'index.cjs', codegraph: { id: 'browser-example', apiVersion: 1, capabilities: ['frameworks'] },
}, files: { 'index.cjs': `module.exports=()=>({frameworks:[{name:'route',languages:['typescript'],detect:()=>true,resolve:()=>null,
extract(file){${broken ? "throw Error('Rejected browser update');" : ''} return {nodes:[{id:'plugin:browser-example:'+file,kind:'route',name:'/browser-${version}',qualifiedName:file+'::browser',filePath:file,language:'typescript',startLine:1,endLine:1,startColumn:0,endColumn:0,updatedAt:0}],references:[]}}}]});` } }));
}
function routes(root) {
const file = path.join(root, '.codegraph/codegraph.db');
if (!fs.existsSync(file)) return [];
const db = new DatabaseSync(file, { readOnly: true });
try { return db.prepare("SELECT name FROM nodes WHERE kind='route' ORDER BY name").all().map(n => n.name); } finally { db.close(); }
}
(async () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-browser-recovery-'));
const roots = ['one', 'two'].map(n => path.join(temp, n));
roots.forEach(r => { fs.mkdirSync(r); fs.writeFileSync(path.join(r, 'app.ts'), 'export function main() {}'); });
let server, bridge, browser, attacker, debugPage, debugSnapshot;
try {
server = await startMarketplaceServer({ database: path.join(temp, 'registry.db'), publicDirectory: path.resolve('marketplace/public') });
const origin = `http://127.0.0.1:${server.port}`;
bridge = await startExtensionBridge(roots, origin);
const token = new URLSearchParams(new URL(bridge.connectionUrl).hash.slice(1)).get('token');
const snapshot = async () => (await fetch(bridge.url + '/status', { headers: { Authorization: 'Bearer ' + token } })).json();
debugSnapshot = snapshot;
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
context.setDefaultTimeout(15000);
context.setDefaultNavigationTimeout(20000);
const page = await context.newPage();
debugPage = page;
const pageErrors = []; page.on('pageerror', e => pageErrors.push(e.message));
await page.goto(bridge.connectionUrl, { waitUntil: 'domcontentloaded' });
console.log('Opened marketplace');
await page.getByRole('link', { name: 'Publish an extension' }).click();
async function publish(version, broken = false) {
console.log('Publishing', version);
if (new URL(page.url()).pathname !== '/publish') await page.getByRole('link', { name: 'Publish an extension' }).click();
await page.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact(version, broken) });
await page.locator('#name').fill('Browser example');
await page.locator('#publisher').fill('Recovery publisher');
await page.locator('#description').fill('A second framework used for end-to-end validation.');
await page.locator('#source').fill('https://example.com/recovery-source');
await page.locator('#readme').fill('Validation-only extension. Adds one explicit route to each TypeScript file.');
await page.getByRole('button', { name: 'Publish release' }).click();
console.log('Submitted', version);
await page.waitForURL('**/extensions/browser-example');
await page.getByText(version, { exact: true }).waitFor();
}
await publish('1.0.0'); checks.push('real browser signed publisher upload');
const first = (await (await fetch(origin + '/api/extensions')).json())[0];
assert.equal(first.official, false);
// A separate browser has a separate signing identity and cannot claim this id.
const otherContext = await browser.newContext();
otherContext.setDefaultTimeout(15000);
const other = await otherContext.newPage();
await other.goto(origin + '/publish');
await other.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact('9.0.0') });
for (const [id, value] of Object.entries({ name: 'Attempted takeover', publisher: 'Other publisher', description: 'Test', source: 'https://example.com/other', readme: 'Test' })) await other.locator('#' + id).fill(value);
await other.getByRole('button', { name: 'Publish release' }).click();
await other.getByText('This extension id belongs to another publisher', { exact: true }).waitFor();
checks.push('browser publisher ownership rejection');
// Send the real form submission to the real API with a corrupted signature.
await other.route('**/api/publish', async route => {
const body = route.request().postDataJSON(); body.signature = 'invalid';
await route.continue({ postData: JSON.stringify(body) });
});
await other.getByRole('button', { name: 'Publish release' }).click();
await other.locator('#publish-result').filter({ hasText: /signature/i }).waitFor();
await other.unroute('**/api/publish');
checks.push('browser invalid-signature failure state');
const malformed = JSON.parse(artifact('9.0.0')); malformed.format = 'invalid-format';
await other.locator('#artifact').setInputFiles({ name: 'invalid.cgext', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(malformed)) });
await other.locator('#description').fill('Invalid package validation');
await other.locator('#source').fill('https://example.com/invalid');
await other.getByRole('button', { name: 'Publish release' }).click();
await other.locator('#publish-result').filter({ hasText: /package/i }).waitFor();
assert.equal((await (await fetch(origin + '/api/extensions')).json()).length, 1);
await otherContext.close(); checks.push('browser malformed-package rejection without publication');
await page.getByRole('link', { name: 'Marketplace', exact: true }).first().click();
const popupPromise = page.waitForEvent('popup');
await page.locator('#connect').click();
const popup = await popupPromise;
await popup.getByRole('button', { name: 'Allow connection' }).click();
await page.getByText('CodeGraph connected', { exact: true }).waitFor();
await page.locator('#project').selectOption('1');
let completedJob = 0;
await page.getByRole('button', { name: 'Install extension' }).click();
async function settle(expected) {
await page.waitForFunction(previous => state.snapshot && !state.snapshot.busy && state.snapshot.jobId > previous, completedJob);
const current = await snapshot();
completedJob = current.jobId;
console.log('Companion result', JSON.stringify({ progress: current.progress, error: current.error, jobId: current.jobId }));
assert.ok(!current.error, current.error);
await page.getByText(expected, { exact: true }).first().waitFor({ timeout: 60000 });
}
await settle('Extension activated and graph refreshed');
assert.deepEqual(routes(roots[1]), ['/browser-1.0.0']); assert.deepEqual(routes(roots[0]), []);
checks.push('one-click selected-project install verified in SQLite');
await page.screenshot({ path: path.join(out, 'desktop-installed.png'), fullPage: true });
// Real same-origin message from an iframe: right origin, wrong source window.
const before = (await snapshot()).jobId;
await page.evaluate(() => {
window.__recoveryPopup = state.popup;
const iframe = document.createElement('iframe'); iframe.name = 'wrong-source'; document.body.append(iframe);
});
await page.frame({ name: 'wrong-source' }).evaluate(() => parent.__recoveryPopup.postMessage({ channel: 'codegraph-extensions-v1', type: 'command', id: 'wrong-window', command: { action: 'remove', id: 'browser-example', project: '1' } }, '*'));
await page.waitForTimeout(300); assert.equal((await snapshot()).jobId, before);
await page.locator('iframe[name="wrong-source"]').evaluate(node => node.remove());
checks.push('real postMessage wrong-window command ignored');
await publish('2.0.0', true);
await page.getByRole('button', { name: 'Update to 2.0.0' }).click();
await page.getByText(/Extension activation failed/).first().waitFor({ timeout: 60000 });
completedJob = (await snapshot()).jobId;
assert.deepEqual(routes(roots[1]), ['/browser-1.0.0']);
assert.equal((await snapshot()).projects[1].extensions[0].version, '1.0.0');
checks.push('failed browser update preserves working version and graph');
await publish('3.0.0');
await page.getByRole('button', { name: 'Update to 3.0.0' }).click();
await settle('Extension activated and graph refreshed');
assert.deepEqual(routes(roots[1]), ['/browser-3.0.0']);
checks.push('browser update replaces stale graph contributions');
await page.getByRole('button', { name: 'Disable', exact: true }).click();
await page.getByRole('button', { name: 'Enable', exact: true }).waitFor({ timeout: 60000 });
assert.deepEqual(routes(roots[1]), []);
await page.getByRole('button', { name: 'Enable', exact: true }).click();
await page.getByRole('button', { name: 'Disable', exact: true }).waitFor({ timeout: 60000 });
assert.deepEqual(routes(roots[1]), ['/browser-3.0.0']);
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await page.getByRole('button', { name: 'Install extension' }).waitFor({ timeout: 60000 });
await page.getByText('Extension removed and graph refreshed', { exact: true }).first().waitFor();
assert.deepEqual(routes(roots[1]), []); assert.deepEqual(routes(roots[0]), []);
checks.push('browser disable/enable/remove verified in SQLite');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByText('CodeGraph compatibility', { exact: true }).waitFor();
await page.getByText('3.0.0', { exact: true }).waitFor();
await page.screenshot({ path: path.join(out, 'mobile-detail.png'), fullPage: true });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
checks.push('mobile detail fits viewport');
// Bootstrap a companion from a hostile origin, even with a valid test token:
// its real message origin must still be refused after the local allow click.
attacker = http.createServer((req, res) => { res.setHeader('Content-Type', 'text/html'); res.end('<button id="open">Open</button>'); });
await new Promise(resolve => attacker.listen(0, '127.0.0.1', resolve));
const evil = await context.newPage(); await evil.goto(`http://127.0.0.1:${attacker.address().port}`);
await evil.evaluate(({ url }) => { document.querySelector('#open').onclick = () => { window.target = window.open(url, '_blank'); }; }, { url: bridge.url + '/#token=' + token });
const badPopupPromise = evil.waitForEvent('popup'); await evil.locator('#open').click(); const badPopup = await badPopupPromise;
await badPopup.getByRole('button', { name: 'Allow connection' }).click();
const beforeOrigin = (await snapshot()).jobId;
await evil.evaluate(() => window.target.postMessage({ channel: 'codegraph-extensions-v1', type: 'command', id: 'wrong-origin', command: { action: 'enable', id: 'browser-example', project: '1' } }, '*'));
await evil.waitForTimeout(300); assert.equal((await snapshot()).jobId, beforeOrigin);
checks.push('real postMessage wrong-origin command ignored');
assert.deepEqual(pageErrors, []);
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ browser: browser.version(), platform: process.platform, checks, pageErrors }, null, 2));
console.log(JSON.stringify({ checks, browser: browser.version(), platform: process.platform }));
} catch (error) {
await debugPage?.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {});
fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ checks, error: safeError(error), snapshot: await debugSnapshot?.() }, null, 2)); throw Error(safeError(error));
} finally {
await browser?.close(); await bridge?.close(); await server?.close();
if (attacker) await new Promise(resolve => attacker.close(resolve));
fs.rmSync(temp, { recursive: true, force: true });
}
})().catch(error => { console.error(safeError(error)); process.exitCode = 1; });