mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 14:49:36 +08:00
Recover project extensions and validate worker and marketplace lifecycle
This commit is contained in:
@@ -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');
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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-')));
|
||||
});
|
||||
@@ -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; });
|
||||
Reference in New Issue
Block a user