From 82a7225eb0fbda7498148366095fd8843b6586dd Mon Sep 17 00:00:00 2001 From: Ignaciobrenas Date: Sat, 14 Nov 2026 23:44:17 +0100 Subject: [PATCH] test(qa): add fuzzing, MCP E2E, 50k benchmark, WCAG 2.1 AA audits, and CI smoke runner (Refs #2, #5, #18, #19) Also fixes two bugs surfaced by the new QA suite: IssueBranchLinker.createSlug leading hyphens and GraphLayout converging-lane O(N^2) memory growth. --- .github/workflows/ci.yml | 6 + package.json | 2 + scripts/benchmark-50k.js | 132 +++++++++ scripts/run-e2e-smoke.js | 193 +++++++++++++ scripts/verify-responsive.js | 468 ++++++++++++++++++-------------- src/graph/graphLayout.ts | 7 +- src/issues/issueBranchLinker.ts | 2 +- src/utils/ipcValidator.ts | 2 +- src/webview/main.ts | 25 ++ test/fuzzTests.ts | 266 ++++++++++++++++++ test/mcpE2eTests.ts | 236 ++++++++++++++++ test/runTests.ts | 12 +- 12 files changed, 1142 insertions(+), 209 deletions(-) create mode 100644 scripts/benchmark-50k.js create mode 100644 scripts/run-e2e-smoke.js create mode 100644 test/fuzzTests.ts create mode 100644 test/mcpE2eTests.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005a543..1f8937b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,11 @@ jobs: - name: Compile and Bundle Production Assets run: npm run build + - name: Run Headless E2E Smoke Suite + run: npm run test:e2e + + - name: Run 50k DAG Performance Benchmark + run: npm run benchmark + - name: Verify VSIX Package Creation run: npx vsce package --no-git-tag-version diff --git a/package.json b/package.json index d53b0ab..3914c48 100644 --- a/package.json +++ b/package.json @@ -403,6 +403,8 @@ "dev": "node esbuild.js --watch", "test": "node -e \"require('esbuild').buildSync({entryPoints:['test/runTests.ts'],outfile:'dist/test.js',bundle:true,platform:'node',alias:{'vscode':'./test/vscodeMock.ts'}}); require('./dist/test.js')\"", "test:live": "node -e \"require('esbuild').buildSync({entryPoints:['test/verifyLiveIntegration.ts'],outfile:'dist/verifyLive.js',bundle:true,platform:'node',alias:{'vscode':'./test/vscodeMock.ts'}}); require('./dist/verifyLive.js')\"", + "test:e2e": "node scripts/run-e2e-smoke.js", + "benchmark": "node scripts/benchmark-50k.js", "mcp": "node dist/mcp.js", "package": "vsce package" }, diff --git a/scripts/benchmark-50k.js b/scripts/benchmark-50k.js new file mode 100644 index 0000000..34e14af --- /dev/null +++ b/scripts/benchmark-50k.js @@ -0,0 +1,132 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { performance } = require('perf_hooks'); +const esbuild = require('esbuild'); + +function loadDagModules() { + const repoRoot = path.resolve(__dirname, '..'); + const tmpOut = path.join(os.tmpdir(), `commitflow-bench-${process.pid}-${Date.now()}.js`); + esbuild.buildSync({ + stdin: { + contents: ` + export { GraphLayout } from './src/graph/graphLayout'; + export { DagWorkerEngine } from './src/graph/dagWorkerClient'; + `, + resolveDir: repoRoot, + loader: 'ts', + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: tmpOut, + }); + + try { + return require(tmpOut); + } finally { + try { + fs.rmSync(tmpOut, { force: true }); + } catch { + // ignore cleanup errors on temp file + } + } +} + +function generateSyntheticCommits(count) { + const commits = new Array(count); + const baseMs = Date.UTC(2026, 0, 1, 0, 0, 0); + const hexHashes = new Array(count); + + for (let i = 0; i < count; i++) { + hexHashes[i] = i.toString(16).padStart(8, '0').padEnd(40, 'a'); + } + + for (let i = 0; i < count; i++) { + const parentHashes = []; + if (i + 1 < count) { + parentHashes.push(hexHashes[i + 1]); + } + // Every 20th commit forms a merge/branch topology edge + if (i % 20 === 0 && i + 7 < count) { + parentHashes.push(hexHashes[i + 7]); + } + + const author = { + name: i % 4 === 0 ? 'Elena' : 'Ignacio', + email: 'dev@commitflow.local', + date: new Date(baseMs - i * 60000), + relativeTime: `${i}m ago`, + avatarUrl: '', + }; + + commits[i] = { + hash: hexHashes[i], + shortHash: hexHashes[i].slice(0, 7), + parentHashes, + author, + committer: author, + subject: i % 20 === 0 ? `merge: integrate topic-${i} into main` : `feat(dag): commit #${i}`, + body: '', + refs: + i === 0 + ? [{ type: 'head', name: 'main', isHead: true }] + : i % 2500 === 0 + ? [{ type: 'tag', name: `v0.${Math.floor(i / 2500)}.0`, isHead: false }] + : [], + }; + } + + return commits; +} + +async function runBenchmark() { + const COMMIT_COUNT = 50000; + const BUDGET_MS = 2500; + + console.log(`\n======================================================`); + console.log(`CommitFlow 50k DAG Layout Performance Benchmark`); + console.log(`======================================================`); + + const { GraphLayout, DagWorkerEngine } = loadDagModules(); + const commits = generateSyntheticCommits(COMMIT_COUNT); + + const memBeforeSync = process.memoryUsage().heapUsed; + const t0 = performance.now(); + const syncNodes = GraphLayout.compute(commits, 'cyberpunk'); + const syncElapsedMs = performance.now() - t0; + const memAfterSync = process.memoryUsage().heapUsed; + + assert.strictEqual(syncNodes.length, COMMIT_COUNT, `Expected ${COMMIT_COUNT} nodes from GraphLayout.compute`); + assert.ok( + syncElapsedMs < BUDGET_MS, + `GraphLayout.compute(50k) exceeded ${BUDGET_MS}ms budget: ${syncElapsedMs.toFixed(2)}ms` + ); + + const engine = DagWorkerEngine.getInstance(); + const t1 = performance.now(); + const asyncNodes = await engine.computeLayoutAsync(commits, 'cyberpunk'); + const asyncElapsedMs = performance.now() - t1; + engine.dispose(); + + assert.strictEqual(asyncNodes.length, COMMIT_COUNT, `Expected ${COMMIT_COUNT} nodes from DagWorkerEngine`); + assert.ok( + asyncElapsedMs < BUDGET_MS, + `DagWorkerEngine.computeLayoutAsync(50k) exceeded ${BUDGET_MS}ms budget: ${asyncElapsedMs.toFixed(2)}ms` + ); + + const deltaMb = ((memAfterSync - memBeforeSync) / (1024 * 1024)).toFixed(2); + const throughput = Math.round(COMMIT_COUNT / (syncElapsedMs / 1000)); + + console.log(` Commits rendered : ${COMMIT_COUNT.toLocaleString()}`); + console.log(` GraphLayout sync : ${syncElapsedMs.toFixed(2)} ms (${throughput.toLocaleString()} commits/sec)`); + console.log(` DagWorkerEngine : ${asyncElapsedMs.toFixed(2)} ms`); + console.log(` Heap delta (sync) : ${deltaMb} MB`); + console.log(` SLA Budget : < ${BUDGET_MS} ms [PASS]\n`); +} + +runBenchmark().catch((err) => { + console.error('[CommitFlow] 50k Benchmark failed:', err); + process.exit(1); +}); diff --git a/scripts/run-e2e-smoke.js b/scripts/run-e2e-smoke.js new file mode 100644 index 0000000..d6bfdf3 --- /dev/null +++ b/scripts/run-e2e-smoke.js @@ -0,0 +1,193 @@ +const assert = require('assert'); +const fs = require('fs'); +const Module = require('module'); +const os = require('os'); +const path = require('path'); +const esbuild = require('esbuild'); + +const ROOT = path.resolve(__dirname, '..'); + +function verifyDistArtifacts() { + const requiredBundles = [ + 'dist/extension.js', + 'dist/webview.js', + 'dist/webview.css', + 'dist/codicon.css', + 'dist/mcp.js', + ]; + + const stats = {}; + for (const rel of requiredBundles) { + const abs = path.join(ROOT, rel); + assert.ok(fs.existsSync(abs), `Missing compiled bundle artifact: ${rel}`); + const st = fs.statSync(abs); + assert.ok(st.size > 0, `Empty bundle artifact: ${rel}`); + stats[rel] = st.size; + } + return stats; +} + +function verifyManifest() { + const pkgPath = path.join(ROOT, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + assert.ok(pkg.main && pkg.main.includes('dist/extension.js'), `Unexpected manifest main: ${pkg.main}`); + assert.ok(Array.isArray(pkg.contributes?.commands) && pkg.contributes.commands.length >= 15, 'Missing contributed commands'); + assert.ok(pkg.contributes?.views && Object.keys(pkg.contributes.views).length > 0, 'Missing contributed views'); + assert.ok(pkg.contributes?.configuration?.properties, 'Missing contributed configuration properties'); + + return pkg; +} + +async function verifyHeadlessExtensionLifecycle(pkg) { + const tmpMockFile = path.join(os.tmpdir(), `cf-vscodemock-${process.pid}-${Date.now()}.js`); + esbuild.buildSync({ + entryPoints: [path.join(ROOT, 'test', 'vscodeMock.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: tmpMockFile, + }); + + const originalLoad = Module._load; + const registeredCommands = new Set(); + const registeredProviders = new Set(); + + try { + const rawMock = require(tmpMockFile); + const instrumentedVscode = { + ...rawMock, + env: { + ...rawMock.env, + language: 'en', + }, + commands: { + ...rawMock.commands, + registerCommand: (name, handler) => { + registeredCommands.add(name); + return rawMock.commands.registerCommand(name, handler); + }, + }, + window: { + ...rawMock.window, + registerWebviewViewProvider: (viewId, provider, options) => { + registeredProviders.add(viewId); + return rawMock.window.registerWebviewViewProvider(viewId, provider, options); + }, + }, + }; + + Module._load = function (request, parent, isMain) { + if (request === 'vscode') { + return instrumentedVscode; + } + return originalLoad.apply(this, arguments); + }; + + const extBundlePath = path.join(ROOT, 'dist', 'extension.js'); + delete require.cache[require.resolve(extBundlePath)]; + const ext = require(extBundlePath); + + assert.strictEqual(typeof ext.activate, 'function', 'dist/extension.js must export activate()'); + assert.strictEqual(typeof ext.deactivate, 'function', 'dist/extension.js must export deactivate()'); + + const secretMap = new Map(); + const stateMap = new Map(); + const mockContext = { + subscriptions: [], + extensionUri: rawMock.Uri.file(ROOT), + extensionPath: ROOT, + globalState: { + get: (k, d) => (stateMap.has(k) ? stateMap.get(k) : d), + update: (k, v) => { + stateMap.set(k, v); + return Promise.resolve(); + }, + keys: () => Array.from(stateMap.keys()), + }, + workspaceState: { + get: (k, d) => (stateMap.has(k) ? stateMap.get(k) : d), + update: (k, v) => { + stateMap.set(k, v); + return Promise.resolve(); + }, + keys: () => Array.from(stateMap.keys()), + }, + secrets: { + get: (k) => Promise.resolve(secretMap.get(k)), + store: (k, v) => { + secretMap.set(k, v); + return Promise.resolve(); + }, + delete: (k) => { + secretMap.delete(k); + return Promise.resolve(); + }, + }, + }; + + await Promise.resolve(ext.activate(mockContext)); + // Allow background initialization (e.g., async server startup) to settle before deactivate() + await new Promise((r) => setTimeout(r, 150)); + + assert.ok( + mockContext.subscriptions.length >= 10, + `Expected >= 10 subscriptions registered during activate(), got ${mockContext.subscriptions.length}` + ); + assert.ok(registeredCommands.has('commitflow.openGraph'), 'Missing commitflow.openGraph command registration'); + assert.ok(registeredCommands.has('commitflow.connectAi'), 'Missing commitflow.connectAi command registration'); + assert.ok( + registeredCommands.size >= pkg.contributes.commands.length - 2, + `Too few commands registered (${registeredCommands.size} vs ${pkg.contributes.commands.length} in manifest)` + ); + assert.ok(registeredProviders.size >= 1, 'Expected at least 1 registered WebviewViewProvider'); + + ext.deactivate(); + for (const sub of mockContext.subscriptions) { + try { + if (sub && typeof sub.dispose === 'function') sub.dispose(); + } catch { + // ignore mock dispose errors + } + } + await new Promise((r) => setTimeout(r, 80)); + + return { + subscriptions: mockContext.subscriptions.length, + registeredCommands: registeredCommands.size, + registeredProviders: registeredProviders.size, + }; + } finally { + Module._load = originalLoad; + try { + fs.rmSync(tmpMockFile, { force: true }); + } catch { + // ignore temp cleanup failure + } + } +} + +async function main() { + console.log(`\n======================================================`); + console.log(`CommitFlow Headless E2E Smoke Suite`); + console.log(`======================================================`); + + const bundleStats = verifyDistArtifacts(); + console.log(` ✔ Bundles verified (${Object.keys(bundleStats).length} artifacts non-empty)`); + + const pkg = verifyManifest(); + console.log(` ✔ Manifest verified (${pkg.contributes.commands.length} contributed commands)`); + + const lifecycle = await verifyHeadlessExtensionLifecycle(pkg); + console.log( + ` ✔ Extension activate/deactivate OK (${lifecycle.registeredCommands} commands, ${lifecycle.registeredProviders} webview views, ${lifecycle.subscriptions} disposables)` + ); + console.log(`======================================================\n`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('[CommitFlow] E2E smoke suite failed:', err); + process.exit(1); + }); diff --git a/scripts/verify-responsive.js b/scripts/verify-responsive.js index b9ec7d8..7777f97 100644 --- a/scripts/verify-responsive.js +++ b/scripts/verify-responsive.js @@ -62,11 +62,31 @@ const themeVariables = { --vscode-testing-iconPassed: #89d185; --vscode-editorWarning-foreground: #cca700; `, + 'high-contrast-light': ` + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #292929; + --vscode-sideBar-background: #ffffff; + --vscode-sideBar-foreground: #292929; + --vscode-input-background: #ffffff; + --vscode-input-foreground: #292929; + --vscode-input-border: #0f4a85; + --vscode-contrastBorder: #0f4a85; + --vscode-contrastActiveBorder: #006bbd; + --vscode-button-background: #0f4a85; + --vscode-button-foreground: #ffffff; + --vscode-button-border: #0f4a85; + --vscode-button-hoverBackground: #0c3b6b; + --vscode-button-secondaryBackground: #ffffff; + --vscode-button-secondaryForeground: #292929; + --vscode-errorForeground: #b5200d; + --vscode-testing-iconPassed: #374e06; + --vscode-editorWarning-foreground: #895503; + `, }; function buildGraphHtml(themeName) { return ` - + @@ -83,7 +103,7 @@ function buildGraphHtml(themeName) { } - +
@@ -95,64 +115,58 @@ function buildGraphHtml(themeName) { main
- - - - - - - - - - - - + + + + + + + + + + + +
- - + +
- - + +
-
Commits: 128
-
Autores: 4
-
Ramas: 6
-
Stashes: 1
+
Commits: 128
+
Autores: 4
+
Ramas: 6
+
Stashes: 1
-
- - - - - - - - - - - - - - - - - -
cf4821amain feat(mcp): streamable HTTP and stdio authenticated serverIgnacioJust now
ab8291fdev feat(ai): Connect with your AI studio and key generatorIgnacio10m ago
+
+
+
+ + cf4821a + main feat(mcp): streamable HTTP and stdio authenticated server +
+
+ + ab8291f + dev feat(ai): Connect with your AI studio and key generator +
+
@@ -161,7 +175,7 @@ function buildGraphHtml(themeName) { function buildRebaseStudioHtml(themeName) { return ` - + @@ -177,53 +191,53 @@ function buildRebaseStudioHtml(themeName) { } - +