mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 01:07:18 +08:00
217 lines
17 KiB
JavaScript
217 lines
17 KiB
JavaScript
// 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 } = process.env.CLOUDFLARE_REGISTRY === '1' ? require('./cloudflare-registry.cjs') : 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(process.env.BROWSER_OUTPUT || '.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: 'hosted-browser-20260922c', 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:hosted-browser-20260922c:'+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, https;
|
|
try {
|
|
let origin = process.env.HOSTED_REGISTRY;
|
|
assert.match(origin || '', /^https:\/\/[a-z0-9.-]+\.workers\.dev$/);
|
|
assert.equal((await(await fetch(origin+'/api/extensions/hosted-browser-20260922c')).json()).length,0,'Fixture already exists; use a fresh isolated target or rename this owned fixture');
|
|
if (process.env.REGISTRY_TEST_HTTPS === '1') { https = await require('./https-marketplace.cjs').httpsMarketplace(server.port, out); origin = https.origin; }
|
|
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(30000);
|
|
let lastPublication=0;
|
|
async function publicationSlot(route){await new Promise(r=>setTimeout(r,Math.max(0,lastPublication+7500-Date.now())));lastPublication=Date.now();await route.continue();}
|
|
await context.route('**/api/publish',publicationSlot);
|
|
context.setDefaultNavigationTimeout(20000);
|
|
const page = await context.newPage();
|
|
debugPage = page;
|
|
const pageErrors = []; page.on('pageerror', e => pageErrors.push(e.message));
|
|
let releaseCatalog, firstCatalog = true;
|
|
await page.route('**/api/extensions', async route => {
|
|
if (firstCatalog) { firstCatalog = false; await new Promise(resolve => { releaseCatalog = resolve; }); }
|
|
await route.continue();
|
|
});
|
|
await page.goto(bridge.connectionUrl, { waitUntil: 'domcontentloaded' });
|
|
if (origin.startsWith('https:')) assert.equal(await page.evaluate(() => isSecureContext), true);
|
|
// Reproduce slow native file reads: metadata must never erase typed fields.
|
|
await page.evaluate(() => { const original=File.prototype.text; File.prototype.text=async function(){await new Promise(resolve=>setTimeout(resolve,500));return original.call(this);}; });
|
|
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.locator('#package-summary').filter({ hasText: 'v'+version+' ·' }).waitFor();
|
|
if (releaseCatalog) {
|
|
const response = page.waitForResponse(r=>r.url().endsWith('/api/extensions'));
|
|
releaseCatalog(); releaseCatalog = undefined; await response;
|
|
await page.waitForTimeout(200);
|
|
assert.equal(await page.locator('#artifact').evaluate(input=>input.files[0]?.name),'example.cgext');
|
|
checks.push('late catalog response preserves the entered publisher form and package');
|
|
}
|
|
assert.equal(await page.locator('#description').inputValue(), 'A second framework used for end-to-end validation.');
|
|
assert.equal(await page.locator('#source').inputValue(), 'https://example.com/recovery-source');
|
|
await page.getByRole('button', { name: 'Publish release' }).click();
|
|
console.log('Submitted', version);
|
|
await page.waitForURL('**/extensions/hosted-browser-20260922c');
|
|
await page.getByText(version, { exact: true }).waitFor();
|
|
}
|
|
await publish('1.0.0'); checks.push('real browser signed publisher upload'); checks.push('slow package metadata preserves typed publisher fields');
|
|
const first = (await (await fetch(origin + '/api/extensions')).json()).find(x=>x.id==='hosted-browser-20260922c');
|
|
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(30000);
|
|
await otherContext.route('**/api/publish',publicationSlot);
|
|
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/hosted-browser-20260922c')).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');
|
|
await page.locator('a[href="/extensions/hosted-browser-20260922c"]').first().click();
|
|
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: 'hosted-browser-20260922c', 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: 'hosted-browser-20260922c', project: '1' } }, '*'));
|
|
await evil.waitForTimeout(300); assert.equal((await snapshot()).jobId, beforeOrigin);
|
|
checks.push('real postMessage wrong-origin command ignored');
|
|
await popup.close();
|
|
await page.getByText('Reconnect CodeGraph', { exact: true }).waitFor();
|
|
checks.push('closed local companion expires the connection visibly');
|
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
await page.getByRole('link', { name: 'Publish an extension' }).click();
|
|
await page.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact('4.0.0') });
|
|
for (const [id, value] of Object.entries({ name: 'Browser example', publisher: 'Recovery publisher', description: 'Outage check', source: 'https://example.com/source', readme: 'Outage check' })) await page.locator('#'+id).fill(value);
|
|
await page.locator('#package-summary').filter({ hasText: 'v4.0.0' }).waitFor();
|
|
await page.route('**/api/publish', route=>route.abort('connectionfailed'));
|
|
await page.getByRole('button', { name: 'Publish release' }).click();
|
|
await page.locator('#publish-result').filter({ hasText: /marketplace registry/i }).waitFor();
|
|
assert.equal(await page.getByRole('button', { name: 'Publish release' }).isEnabled(), true);
|
|
assert.deepEqual(routes(roots[1]), []);
|
|
checks.push('injected client transport outage shows actionable publisher failure (provider remains running) without changing local graph');
|
|
assert.deepEqual(pageErrors, []);
|
|
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ origin, 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 https?.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)); if(error.cause) console.error(error.cause); process.exitCode = 1; });
|