@lamalibre/install-portlama-e2e-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // e2e-mcp — Dual-mode entry point
4
+ // ============================================================================
5
+ // Interactive (TTY): Install mode — configures Claude Code MCP settings
6
+ // Piped (stdio): Server mode — runs as MCP server
7
+ //
8
+ // Usage:
9
+ // npx @lamalibre/install-portlama-e2e-mcp # install & configure
10
+ // npx @lamalibre/install-portlama-e2e-mcp --install # force install mode
11
+ // npx @lamalibre/install-portlama-e2e-mcp --server # force server mode
12
+ // ============================================================================
13
+
14
+ const args = process.argv.slice(2);
15
+ const forceInstall = args.includes('--install');
16
+ const forceServer = args.includes('--server');
17
+
18
+ try {
19
+ if (forceServer) {
20
+ await import('../src/index.js');
21
+ } else if (forceInstall || process.stdin.isTTY) {
22
+ const { install } = await import('../src/install.js');
23
+ await install();
24
+ } else {
25
+ await import('../src/index.js');
26
+ }
27
+ } catch (error) {
28
+ console.error(`\n Error: ${error.message}\n`);
29
+ process.exit(1);
30
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@lamalibre/install-portlama-e2e-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Portlama E2E test infrastructure — VM lifecycle, snapshots, test execution",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "author": "Code Lama Software",
8
+ "main": "src/index.js",
9
+ "bin": {
10
+ "install-portlama-e2e-mcp": "./bin/install-portlama-e2e-mcp.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "start": "node src/index.js",
18
+ "build": "echo 'No build step for e2e-mcp'"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.27.1",
22
+ "chalk": "^5.3.0",
23
+ "execa": "^9.6.1",
24
+ "listr2": "^8.0.0",
25
+ "zod": "^3.23.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/lamalibre/portlama.git",
30
+ "directory": "packages/install-portlama-e2e-mcp"
31
+ },
32
+ "homepage": "https://github.com/lamalibre/portlama#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/lamalibre/portlama/issues"
35
+ },
36
+ "keywords": [
37
+ "portlama",
38
+ "e2e",
39
+ "mcp",
40
+ "testing",
41
+ "multipass",
42
+ "model-context-protocol"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20.0.0"
46
+ }
47
+ }
package/src/config.js ADDED
@@ -0,0 +1,60 @@
1
+ // ============================================================================
2
+ // E2E MCP — Configuration & Constants
3
+ // ============================================================================
4
+
5
+ import { fileURLToPath } from 'node:url';
6
+ import path from 'node:path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ /** Root of the portlama repository. */
12
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
13
+
14
+ /** E2E test directories. */
15
+ export const SINGLE_VM_DIR = path.join(REPO_ROOT, 'tests', 'e2e');
16
+ export const THREE_VM_DIR = path.join(REPO_ROOT, 'tests', 'e2e-three-vm');
17
+ export const E2E_LOGS_DIR = path.join(REPO_ROOT, 'e2e-logs');
18
+
19
+ /** Temp directory for intermediate run data. */
20
+ export const TEMP_DIR = '/tmp/portlama-e2e';
21
+
22
+ /** VM names. */
23
+ export const VM_HOST = 'portlama-host';
24
+ export const VM_AGENT = 'portlama-agent';
25
+ export const VM_VISITOR = 'portlama-visitor';
26
+ export const ALL_VMS = [VM_HOST, VM_AGENT, VM_VISITOR];
27
+
28
+ /** Default test domain. */
29
+ export const TEST_DOMAIN = 'test.portlama.local';
30
+
31
+ /** VM short-name → full multipass name mapping. */
32
+ export const VM_NAME_MAP = { host: VM_HOST, agent: VM_AGENT, visitor: VM_VISITOR };
33
+
34
+ /** VM profiles — resource allocation tiers. */
35
+ export const PROFILES = {
36
+ production: {
37
+ description: 'Matches $4 DigitalOcean droplet — final publishable runs only',
38
+ cpus: 1,
39
+ memory: '512M',
40
+ disk: '10G',
41
+ },
42
+ development: {
43
+ description: 'Fast iteration — logic correctness, comfortable resources',
44
+ cpus: 2,
45
+ memory: '2G',
46
+ disk: '10G',
47
+ },
48
+ performance: {
49
+ description: 'Heavy lifting — parallel tests, fast builds',
50
+ cpus: 4,
51
+ memory: '4G',
52
+ disk: '20G',
53
+ },
54
+ };
55
+
56
+ /** Snapshot checkpoints — named save-points in the VM lifecycle. */
57
+ export const CHECKPOINTS = {
58
+ 'post-create': 'VMs exist but no setup has run',
59
+ 'post-setup': 'All VMs provisioned, onboarding complete, services running',
60
+ };
package/src/index.js ADDED
@@ -0,0 +1,96 @@
1
+ // ============================================================================
2
+ // Portlama E2E MCP Server
3
+ // ============================================================================
4
+ // MCP server for managing the E2E test infrastructure: VM lifecycle, snapshots,
5
+ // provisioning, test execution with dependency resolution, and two-tier logging.
6
+ //
7
+ // Usage:
8
+ // node src/index.js # stdio transport (for Claude Code)
9
+ // e2e-mcp # via bin link
10
+ //
11
+ // Tools:
12
+ // env_detect — detect hardware, recommend VM profile
13
+ // vm_create — create VMs with a resource profile
14
+ // vm_list — list running VMs
15
+ // vm_delete — tear down VMs
16
+ // vm_exec — execute command on a VM
17
+ // snapshot_create — snapshot VMs at a checkpoint
18
+ // snapshot_restore — restore VMs to a checkpoint
19
+ // snapshot_list — list available snapshots
20
+ // provision_host — full host provisioning pipeline
21
+ // provision_agent — agent setup with cert transfer
22
+ // provision_visitor — visitor setup
23
+ // hot_reload — re-pack and redeploy a single package
24
+ // test_run — run a specific test with dependency resolution
25
+ // test_run_all — run full test suite
26
+ // test_list — list tests with dependency graph
27
+ // test_reset — reset state between tests
28
+ // test_publish — full production run with markdown logs
29
+ // env_status — full environment health check
30
+ // test_log — fetch raw log for a test run
31
+ // ============================================================================
32
+
33
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
34
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
35
+
36
+ import { envDetectTool } from './tools/env.js';
37
+ import { vmCreateTool, vmListTool, vmDeleteTool, vmExecTool } from './tools/vm.js';
38
+ import {
39
+ snapshotCreateTool,
40
+ snapshotRestoreTool,
41
+ snapshotListTool,
42
+ } from './tools/snapshots.js';
43
+ import {
44
+ provisionHostTool,
45
+ provisionAgentTool,
46
+ provisionVisitorTool,
47
+ hotReloadTool,
48
+ } from './tools/provision.js';
49
+ import {
50
+ testRunTool,
51
+ testRunAllTool,
52
+ testListTool,
53
+ testResetTool,
54
+ testPublishTool,
55
+ } from './tools/tests.js';
56
+ import { envStatusTool, testLogTool } from './tools/status.js';
57
+
58
+ const server = new McpServer({
59
+ name: 'portlama-e2e',
60
+ version: '0.1.0',
61
+ });
62
+
63
+ // Register all tools
64
+ const tools = [
65
+ envDetectTool,
66
+ vmCreateTool,
67
+ vmListTool,
68
+ vmDeleteTool,
69
+ vmExecTool,
70
+ snapshotCreateTool,
71
+ snapshotRestoreTool,
72
+ snapshotListTool,
73
+ provisionHostTool,
74
+ provisionAgentTool,
75
+ provisionVisitorTool,
76
+ hotReloadTool,
77
+ testRunTool,
78
+ testRunAllTool,
79
+ testListTool,
80
+ testResetTool,
81
+ testPublishTool,
82
+ envStatusTool,
83
+ testLogTool,
84
+ ];
85
+
86
+ for (const tool of tools) {
87
+ // MCP SDK expects raw Zod shape ({ key: z.string() }), not z.object({ ... })
88
+ const shape = tool.inputSchema.shape || {};
89
+ server.tool(tool.name, tool.description, shape, async (args) => {
90
+ return tool.handler(args || {});
91
+ });
92
+ }
93
+
94
+ // Start
95
+ const transport = new StdioServerTransport();
96
+ await server.connect(transport);
package/src/install.js ADDED
@@ -0,0 +1,262 @@
1
+ // ============================================================================
2
+ // Interactive Installer
3
+ // ============================================================================
4
+ // Detects the portlama repo, checks prerequisites, and configures Claude Code
5
+ // to use the e2e-mcp server via `claude mcp add`.
6
+ //
7
+ // Usage: npx @lamalibre/install-portlama-e2e-mcp
8
+ //
9
+ // Steps:
10
+ // 1. Detect portlama repository root
11
+ // 2. Verify prerequisites (multipass, node >= 20, claude CLI)
12
+ // 3. Install npm dependencies
13
+ // 4. Verify MCP server loads
14
+ // 5. Register MCP server via `claude mcp add`
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import chalk from 'chalk';
20
+ import { Listr } from 'listr2';
21
+ import { execa } from 'execa';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+
26
+ /** Find portlama repo root by walking up from cwd or package location. */
27
+ function findRepoRoot() {
28
+ // First try: walk up from cwd
29
+ let dir = process.cwd();
30
+ while (dir !== path.dirname(dir)) {
31
+ if (
32
+ fs.existsSync(path.join(dir, 'CLAUDE.md')) &&
33
+ fs.existsSync(path.join(dir, 'packages', 'panel-server'))
34
+ ) {
35
+ return dir;
36
+ }
37
+ dir = path.dirname(dir);
38
+ }
39
+
40
+ // Second try: relative to this package (when installed in the monorepo)
41
+ const candidate = path.resolve(__dirname, '..', '..', '..');
42
+ if (
43
+ fs.existsSync(path.join(candidate, 'CLAUDE.md')) &&
44
+ fs.existsSync(path.join(candidate, 'packages', 'panel-server'))
45
+ ) {
46
+ return candidate;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export async function install() {
53
+ console.log('');
54
+ console.log(
55
+ chalk.bold.cyan(
56
+ ' ┌─────────────────────────────────────────────┐',
57
+ ),
58
+ );
59
+ console.log(
60
+ chalk.bold.cyan(
61
+ ' │ Portlama E2E MCP Server Installer │',
62
+ ),
63
+ );
64
+ console.log(
65
+ chalk.bold.cyan(
66
+ ' └─────────────────────────────────────────────┘',
67
+ ),
68
+ );
69
+ console.log('');
70
+
71
+ const ctx = {
72
+ repoRoot: null,
73
+ serverPath: null,
74
+ multipassVersion: null,
75
+ nodeVersion: null,
76
+ claudeVersion: null,
77
+ };
78
+
79
+ const tasks = new Listr(
80
+ [
81
+ {
82
+ title: 'Detecting portlama repository',
83
+ task: async (_ctx, task) => {
84
+ ctx.repoRoot = findRepoRoot();
85
+ if (!ctx.repoRoot) {
86
+ throw new Error(
87
+ 'Could not find portlama repository. Run this from within the repo, ' +
88
+ 'or clone it first: git clone https://github.com/lamalibre/portlama.git',
89
+ );
90
+ }
91
+ ctx.serverPath = path.join(
92
+ ctx.repoRoot,
93
+ 'packages',
94
+ 'install-portlama-e2e-mcp',
95
+ 'src',
96
+ 'index.js',
97
+ );
98
+ task.output = ctx.repoRoot;
99
+ },
100
+ rendererOptions: { persistentOutput: true },
101
+ },
102
+ {
103
+ title: 'Checking Node.js version',
104
+ task: async (_ctx, task) => {
105
+ const version = process.version;
106
+ const major = parseInt(version.slice(1).split('.')[0], 10);
107
+ if (major < 20) {
108
+ throw new Error(
109
+ `Node.js >= 20 required (found ${version}). Install via nvm or nodejs.org.`,
110
+ );
111
+ }
112
+ ctx.nodeVersion = version;
113
+ task.output = version;
114
+ },
115
+ rendererOptions: { persistentOutput: true },
116
+ },
117
+ {
118
+ title: 'Checking Multipass installation',
119
+ task: async (_ctx, task) => {
120
+ try {
121
+ const { stdout } = await execa('multipass', ['version']);
122
+ ctx.multipassVersion = stdout.split('\n')[0];
123
+ task.output = ctx.multipassVersion;
124
+ } catch {
125
+ throw new Error(
126
+ 'Multipass is not installed. Install it from https://multipass.run',
127
+ );
128
+ }
129
+ },
130
+ rendererOptions: { persistentOutput: true },
131
+ },
132
+ {
133
+ title: 'Checking Claude Code CLI',
134
+ task: async (_ctx, task) => {
135
+ try {
136
+ const { stdout } = await execa('claude', ['--version']);
137
+ ctx.claudeVersion = stdout.trim();
138
+ task.output = ctx.claudeVersion;
139
+ } catch {
140
+ throw new Error(
141
+ 'Claude Code CLI not found. Install it from https://claude.com/claude-code',
142
+ );
143
+ }
144
+ },
145
+ rendererOptions: { persistentOutput: true },
146
+ },
147
+ {
148
+ title: 'Installing dependencies',
149
+ task: async (_ctx, task) => {
150
+ const e2eMcpDir = path.join(
151
+ ctx.repoRoot,
152
+ 'packages',
153
+ 'install-portlama-e2e-mcp',
154
+ );
155
+ if (!fs.existsSync(e2eMcpDir)) {
156
+ throw new Error(
157
+ `e2e-mcp package not found at ${e2eMcpDir}. ` +
158
+ 'Make sure you have the latest version of the repo.',
159
+ );
160
+ }
161
+
162
+ // Check if node_modules already exists
163
+ if (
164
+ fs.existsSync(path.join(e2eMcpDir, 'node_modules')) ||
165
+ fs.existsSync(path.join(ctx.repoRoot, 'node_modules'))
166
+ ) {
167
+ task.output = 'Already installed (workspace)';
168
+ return;
169
+ }
170
+
171
+ task.output = 'Running npm install...';
172
+ await execa('npm', ['install'], { cwd: ctx.repoRoot });
173
+ task.output = 'Done';
174
+ },
175
+ rendererOptions: { persistentOutput: true },
176
+ },
177
+ {
178
+ title: 'Verifying MCP server loads',
179
+ task: async (_ctx, task) => {
180
+ const configPath = path.join(
181
+ ctx.repoRoot,
182
+ 'packages',
183
+ 'install-portlama-e2e-mcp',
184
+ 'src',
185
+ 'config.js',
186
+ );
187
+ await execa('node', [
188
+ '-e',
189
+ `import('${configPath.replace(/\\/g, '\\\\')}').then(() => console.log('OK'))`,
190
+ ]);
191
+ task.output = 'Server modules load correctly';
192
+ },
193
+ rendererOptions: { persistentOutput: true },
194
+ },
195
+ {
196
+ title: 'Registering MCP server with Claude Code',
197
+ task: async (_ctx, task) => {
198
+ // Remove existing server if present (idempotent reinstall)
199
+ await execa('claude', ['mcp', 'remove', 'e2e'], {
200
+ reject: false,
201
+ });
202
+
203
+ // Register via claude mcp add
204
+ await execa('claude', [
205
+ 'mcp', 'add',
206
+ '--transport', 'stdio',
207
+ 'e2e',
208
+ '--',
209
+ 'node', ctx.serverPath,
210
+ ]);
211
+
212
+ task.output = 'Registered as "e2e" MCP server';
213
+ },
214
+ rendererOptions: { persistentOutput: true },
215
+ },
216
+ ],
217
+ {
218
+ renderer: 'default',
219
+ rendererOptions: { collapseSubtasks: false },
220
+ exitOnError: true,
221
+ },
222
+ );
223
+
224
+ try {
225
+ await tasks.run();
226
+
227
+ console.log('');
228
+ console.log(chalk.green.bold(' Installation complete!'));
229
+ console.log('');
230
+ console.log(
231
+ chalk.dim(' The following tools are now available in Claude Code:'),
232
+ );
233
+ console.log('');
234
+ console.log(' ' + chalk.cyan('env_detect') + ' — detect hardware, recommend VM profile');
235
+ console.log(' ' + chalk.cyan('vm_create') + ' — create VMs with resource profile');
236
+ console.log(' ' + chalk.cyan('vm_list') + ' — list running VMs');
237
+ console.log(' ' + chalk.cyan('vm_delete') + ' — tear down VMs');
238
+ console.log(' ' + chalk.cyan('vm_exec') + ' — execute command on a VM');
239
+ console.log(' ' + chalk.cyan('snapshot_create') + ' — snapshot VMs at checkpoint');
240
+ console.log(' ' + chalk.cyan('snapshot_restore') + ' — restore VMs to checkpoint');
241
+ console.log(' ' + chalk.cyan('snapshot_list') + ' — list available snapshots');
242
+ console.log(' ' + chalk.cyan('provision_host') + ' — full host provisioning');
243
+ console.log(' ' + chalk.cyan('provision_agent') + ' — agent setup with cert transfer');
244
+ console.log(' ' + chalk.cyan('provision_visitor') + ' — visitor setup');
245
+ console.log(' ' + chalk.cyan('hot_reload') + ' — re-deploy a single package');
246
+ console.log(' ' + chalk.cyan('test_run') + ' — run a test with dependency resolution');
247
+ console.log(' ' + chalk.cyan('test_run_all') + ' — run full test suite');
248
+ console.log(' ' + chalk.cyan('test_list') + ' — list tests with dependency graph');
249
+ console.log(' ' + chalk.cyan('test_reset') + ' — reset state between tests');
250
+ console.log(' ' + chalk.cyan('test_publish') + ' — production run with markdown logs');
251
+ console.log(' ' + chalk.cyan('env_status') + ' — environment health check');
252
+ console.log(' ' + chalk.cyan('test_log') + ' — fetch raw log for debugging');
253
+ console.log('');
254
+ console.log(chalk.dim(' Restart Claude Code to activate the MCP server.'));
255
+ console.log('');
256
+ } catch (err) {
257
+ console.log('');
258
+ console.error(chalk.red(` Installation failed: ${err.message}`));
259
+ console.log('');
260
+ process.exit(1);
261
+ }
262
+ }
@@ -0,0 +1,162 @@
1
+ // ============================================================================
2
+ // Test Discovery & Dependency Graph
3
+ // ============================================================================
4
+ // Discovers test files from the filesystem and verifies they are git-tracked.
5
+ // Only files matching the NN-name.sh convention that are committed to git are
6
+ // eligible for execution — this prevents injected scripts from being run.
7
+
8
+ import fs from 'node:fs';
9
+ import { execSync } from 'node:child_process';
10
+ import { SINGLE_VM_DIR, THREE_VM_DIR, REPO_ROOT } from '../config.js';
11
+
12
+ /** Pattern for valid test files: two-digit prefix, hyphen, name, .sh extension. */
13
+ const TEST_FILE_PATTERN = /^(\d{2})-[a-z0-9-]+\.sh$/;
14
+
15
+ /**
16
+ * Get the set of git-tracked files in a directory (relative to repo root).
17
+ * Returns a Set of filenames (not full paths).
18
+ */
19
+ function getGitTrackedFiles(dir) {
20
+ try {
21
+ const relativePath = dir.replace(REPO_ROOT + '/', '');
22
+ const output = execSync(`git ls-files "${relativePath}"`, {
23
+ cwd: REPO_ROOT,
24
+ encoding: 'utf-8',
25
+ timeout: 5000,
26
+ });
27
+ return new Set(
28
+ output
29
+ .trim()
30
+ .split('\n')
31
+ .filter(Boolean)
32
+ .map((f) => f.split('/').pop()),
33
+ );
34
+ } catch {
35
+ // If git is unavailable, fall back to empty set (all files rejected)
36
+ return new Set();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Discover test files from a directory.
42
+ * Only returns files that:
43
+ * 1. Match the NN-name.sh naming convention
44
+ * 2. Are tracked by git (not injected/untracked)
45
+ * Returns a map of { number: filename }.
46
+ */
47
+ export function discoverTests(dir) {
48
+ const gitTracked = getGitTrackedFiles(dir);
49
+ const files = fs.readdirSync(dir).filter((f) => TEST_FILE_PATTERN.test(f));
50
+ const map = {};
51
+
52
+ for (const file of files) {
53
+ if (!gitTracked.has(file)) continue; // reject untracked files
54
+ const num = parseInt(file.slice(0, 2), 10);
55
+ map[num] = file;
56
+ }
57
+
58
+ return map;
59
+ }
60
+
61
+ /**
62
+ * Single-VM test dependency graph.
63
+ * Key = test number, Value = array of prerequisite test numbers.
64
+ *
65
+ * Test 01 verifies fresh install, 03 runs onboarding. Most tests require
66
+ * onboarding to be complete, so they depend on 03.
67
+ */
68
+ export const SINGLE_VM_DEPS = {
69
+ 1: [], // fresh-install — no deps
70
+ 2: [1], // mtls-enforcement
71
+ 3: [1], // onboarding-flow
72
+ 4: [3], // tunnel-lifecycle (needs onboarding)
73
+ 5: [3], // user-lifecycle
74
+ 6: [1], // service-control
75
+ 7: [3], // cert-renewal
76
+ 8: [3], // mtls-rotation
77
+ 9: [3], // ip-fallback
78
+ 10: [1], // resilience
79
+ 11: [3], // input-validation
80
+ 12: [3], // user-invitations
81
+ 13: [3], // site-lifecycle
82
+ 14: [3], // shell-lifecycle
83
+ 15: [3], // plugin-lifecycle
84
+ 16: [3], // enrollment-tokens
85
+ };
86
+
87
+ /**
88
+ * Three-VM test dependency graph.
89
+ * All tests require onboarding to be complete (test 01 verifies it).
90
+ * Tests are otherwise independent — each cleans up after itself.
91
+ */
92
+ export const THREE_VM_DEPS = {
93
+ 1: [], // onboarding-complete — no deps
94
+ 2: [1], // tunnel-traffic
95
+ 3: [1], // tunnel-toggle-traffic
96
+ 4: [1], // authelia-auth
97
+ 5: [1], // admin-journey
98
+ 6: [1], // tunnel-user-journey
99
+ 7: [1], // site-visitor-journey
100
+ 8: [1], // invitation-journey
101
+ 9: [1], // agent-site-deploy
102
+ 10: [1], // shell-lifecycle
103
+ 11: [1], // plugin-lifecycle
104
+ 12: [1], // enrollment-lifecycle
105
+ };
106
+
107
+ /** Lazily discovered test maps — cached after first call. */
108
+ let _singleVmTests = null;
109
+ let _threeVmTests = null;
110
+
111
+ /** Get the single-VM test file map. Auto-discovered and cached. */
112
+ export function getSingleVmTests() {
113
+ if (!_singleVmTests) _singleVmTests = discoverTests(SINGLE_VM_DIR);
114
+ return _singleVmTests;
115
+ }
116
+
117
+ /** Get the three-VM test file map. Auto-discovered and cached. */
118
+ export function getThreeVmTests() {
119
+ if (!_threeVmTests) _threeVmTests = discoverTests(THREE_VM_DIR);
120
+ return _threeVmTests;
121
+ }
122
+
123
+ /** Invalidate cached test maps (e.g. after adding new tests). */
124
+ export function clearTestCache() {
125
+ _singleVmTests = null;
126
+ _threeVmTests = null;
127
+ }
128
+
129
+ /**
130
+ * Resolve the full dependency chain for a given test number.
131
+ * Returns a sorted array of test numbers that must run (including the target).
132
+ */
133
+ export function resolveDeps(testNumber, depGraph) {
134
+ const visited = new Set();
135
+ const order = [];
136
+
137
+ function walk(n) {
138
+ if (visited.has(n)) return;
139
+ visited.add(n);
140
+ const deps = depGraph[n] || [];
141
+ for (const dep of deps) {
142
+ walk(dep);
143
+ }
144
+ order.push(n);
145
+ }
146
+
147
+ walk(testNumber);
148
+ return order.sort((a, b) => a - b);
149
+ }
150
+
151
+ /**
152
+ * Given a target test, return the minimal set of test filenames to run.
153
+ */
154
+ export function resolveTestChain(testNumber, suite = 'three-vm') {
155
+ const depGraph = suite === 'single-vm' ? SINGLE_VM_DEPS : THREE_VM_DEPS;
156
+ const testMap = suite === 'single-vm' ? getSingleVmTests() : getThreeVmTests();
157
+
158
+ const chain = resolveDeps(testNumber, depGraph);
159
+ return chain
160
+ .map((n) => ({ number: n, file: testMap[n] }))
161
+ .filter((t) => t.file);
162
+ }