@portel/photon 1.10.0 → 1.12.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.
- package/README.md +81 -72
- package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
- package/dist/auto-ui/beam/photon-management.js +5 -0
- package/dist/auto-ui/beam/photon-management.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.d.ts +1 -2
- package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-browse.js +140 -191
- package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +44 -1
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +874 -20
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +83 -60
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +16 -2
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +1 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +2836 -357
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/package-app.d.ts.map +1 -1
- package/dist/cli/commands/package-app.js +116 -35
- package/dist/cli/commands/package-app.js.map +1 -1
- package/dist/context-store.d.ts +5 -0
- package/dist/context-store.d.ts.map +1 -1
- package/dist/context-store.js +9 -0
- package/dist/context-store.js.map +1 -1
- package/dist/daemon/server.js +303 -6
- package/dist/daemon/server.js.map +1 -1
- package/dist/loader.d.ts +21 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +277 -0
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +21 -1
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +6 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +22 -0
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/tunnel.photon.d.ts +5 -9
- package/dist/photons/tunnel.photon.d.ts.map +1 -1
- package/dist/photons/tunnel.photon.js +36 -96
- package/dist/photons/tunnel.photon.js.map +1 -1
- package/dist/photons/tunnel.photon.ts +40 -112
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +27 -2
- package/dist/server.js.map +1 -1
- package/dist/test-runner.d.ts +13 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +529 -122
- package/dist/test-runner.js.map +1 -1
- package/package.json +22 -6
package/dist/test-runner.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Photon Test Runner
|
|
3
3
|
*
|
|
4
|
-
* Discovers and runs
|
|
4
|
+
* Discovers and runs tests from:
|
|
5
|
+
* - External .test.ts files (preferred — companion to .photon.ts)
|
|
6
|
+
* - Inline test* methods in .photon.ts (legacy, backward compatible)
|
|
7
|
+
*
|
|
5
8
|
* Supports multiple test modes:
|
|
6
9
|
* - direct: Call methods directly on instance (unit tests)
|
|
7
10
|
* - cli: Call methods via CLI subprocess (integration tests)
|
|
@@ -10,19 +13,347 @@
|
|
|
10
13
|
* Usage: photon test [photon] [testName] [--mode direct|cli|all]
|
|
11
14
|
*/
|
|
12
15
|
import * as path from 'path';
|
|
13
|
-
import { existsSync } from 'fs';
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
14
17
|
import { spawn } from 'child_process';
|
|
15
|
-
import { fileURLToPath } from 'url';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
16
19
|
import { PhotonLoader } from './loader.js';
|
|
17
20
|
import { listPhotonMCPs, resolvePhotonPath } from './path-resolver.js';
|
|
18
21
|
import { logger } from './shared/logger.js';
|
|
19
|
-
import { SchemaExtractor } from '@portel/photon-core';
|
|
22
|
+
import { SchemaExtractor, compilePhotonTS } from '@portel/photon-core';
|
|
20
23
|
import chalk from 'chalk';
|
|
21
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
25
|
const __dirname = path.dirname(__filename);
|
|
23
26
|
// Get the path to the CLI binary (either local dev or installed)
|
|
24
27
|
const CLI_PATH = path.resolve(__dirname, 'cli.js');
|
|
25
28
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
29
|
+
// EXTERNAL TEST FILE DISCOVERY & EXECUTION
|
|
30
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the companion .test.ts path for a photon file.
|
|
33
|
+
* e.g. /path/to/todo.photon.ts → /path/to/todo.test.ts
|
|
34
|
+
*/
|
|
35
|
+
function resolveTestFilePath(photonPath) {
|
|
36
|
+
const testPath = photonPath.replace(/\.photon\.ts$/, '.test.ts');
|
|
37
|
+
return existsSync(testPath) ? testPath : null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse JSDoc tags (@skip, @only) from a .test.ts source file.
|
|
41
|
+
* Returns a map of export name → { skip?, only? }.
|
|
42
|
+
*/
|
|
43
|
+
function parseTestTags(source) {
|
|
44
|
+
const tags = new Map();
|
|
45
|
+
// Match JSDoc comment followed by export
|
|
46
|
+
const pattern = /\/\*\*([\s\S]*?)\*\/\s*export\s+(?:async\s+)?(?:function\s+|const\s+)(\w+)/g;
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
49
|
+
const comment = match[1];
|
|
50
|
+
const name = match[2];
|
|
51
|
+
const entry = {};
|
|
52
|
+
const skipMatch = comment.match(/@skip(?:\s+(.+?))?(?:\n|\*)/);
|
|
53
|
+
if (skipMatch) {
|
|
54
|
+
entry.skip = skipMatch[1]?.trim() || 'skipped';
|
|
55
|
+
}
|
|
56
|
+
if (/@only\b/.test(comment)) {
|
|
57
|
+
entry.only = true;
|
|
58
|
+
}
|
|
59
|
+
tags.set(name, entry);
|
|
60
|
+
}
|
|
61
|
+
return tags;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Discover and compile a .test.ts file, returning test descriptors and hooks.
|
|
65
|
+
*/
|
|
66
|
+
async function discoverExternalTests(testFilePath) {
|
|
67
|
+
try {
|
|
68
|
+
const source = readFileSync(testFilePath, 'utf-8');
|
|
69
|
+
const tags = parseTestTags(source);
|
|
70
|
+
// Compile the test file
|
|
71
|
+
const cacheDir = path.join(path.dirname(testFilePath), '.photon-cache', 'tests');
|
|
72
|
+
const jsPath = await compilePhotonTS(testFilePath, { cacheDir });
|
|
73
|
+
// Import the compiled module
|
|
74
|
+
const moduleUrl = pathToFileURL(jsPath).href;
|
|
75
|
+
const mod = await import(moduleUrl);
|
|
76
|
+
const tests = [];
|
|
77
|
+
const hooks = {};
|
|
78
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
79
|
+
// Lifecycle hooks
|
|
80
|
+
if (key === 'beforeAll' && typeof value === 'function') {
|
|
81
|
+
hooks.beforeAll = value;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (key === 'afterAll' && typeof value === 'function') {
|
|
85
|
+
hooks.afterAll = value;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (key === 'beforeEach' && typeof value === 'function') {
|
|
89
|
+
hooks.beforeEach = value;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (key === 'afterEach' && typeof value === 'function') {
|
|
93
|
+
hooks.afterEach = value;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Test functions
|
|
97
|
+
if (key.startsWith('test') && typeof value === 'function') {
|
|
98
|
+
const tagInfo = tags.get(key);
|
|
99
|
+
tests.push({
|
|
100
|
+
name: key,
|
|
101
|
+
fn: value,
|
|
102
|
+
skip: tagInfo?.skip,
|
|
103
|
+
only: tagInfo?.only,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Sequence tests (exported arrays of functions)
|
|
107
|
+
if (key.startsWith('test') && Array.isArray(value)) {
|
|
108
|
+
const tagInfo = tags.get(key);
|
|
109
|
+
const steps = value
|
|
110
|
+
.filter((fn) => typeof fn === 'function')
|
|
111
|
+
.map((fn) => ({ name: fn.name || 'anonymous', fn }));
|
|
112
|
+
if (steps.length > 0) {
|
|
113
|
+
tests.push({
|
|
114
|
+
name: key,
|
|
115
|
+
steps,
|
|
116
|
+
skip: tagInfo?.skip,
|
|
117
|
+
only: tagInfo?.only,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { tests, hooks };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
logger.error(`Failed to discover external tests from ${testFilePath}: ${error.message}`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run a single external test function with a fresh photon instance.
|
|
131
|
+
*/
|
|
132
|
+
async function runExternalTest(testFn, photonPath, photonName, testName, workingDir, hooks) {
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
let loadError = null;
|
|
135
|
+
try {
|
|
136
|
+
// Create a fresh instance for isolation
|
|
137
|
+
let instance = null;
|
|
138
|
+
try {
|
|
139
|
+
const loader = new PhotonLoader(false);
|
|
140
|
+
const loaded = await loader.loadFile(photonPath);
|
|
141
|
+
instance = loaded.instance;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
loadError = err.message;
|
|
145
|
+
// Create a minimal stub so test functions can check isConnected() etc.
|
|
146
|
+
instance = {};
|
|
147
|
+
}
|
|
148
|
+
// Run beforeEach hook
|
|
149
|
+
if (hooks?.beforeEach) {
|
|
150
|
+
try {
|
|
151
|
+
await hooks.beforeEach(instance);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// beforeEach may fail on stub instance — ignore
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const result = await testFn(instance);
|
|
159
|
+
const duration = Date.now() - start;
|
|
160
|
+
// Same contract as inline tests: explicit { passed: false } or throw
|
|
161
|
+
if (result && typeof result === 'object') {
|
|
162
|
+
if (result.skipped === true) {
|
|
163
|
+
return {
|
|
164
|
+
photon: photonName,
|
|
165
|
+
test: testName,
|
|
166
|
+
passed: true,
|
|
167
|
+
skipped: true,
|
|
168
|
+
duration,
|
|
169
|
+
message: result.reason || 'Skipped',
|
|
170
|
+
mode: 'direct',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (result.passed === false) {
|
|
174
|
+
const failResult = {
|
|
175
|
+
photon: photonName,
|
|
176
|
+
test: testName,
|
|
177
|
+
passed: false,
|
|
178
|
+
duration,
|
|
179
|
+
error: result.error || result.message || 'Test returned passed: false',
|
|
180
|
+
mode: 'direct',
|
|
181
|
+
};
|
|
182
|
+
failResult.issueUrl = generateIssueUrl(failResult, workingDir);
|
|
183
|
+
return failResult;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { photon: photonName, test: testName, passed: true, duration, mode: 'direct' };
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
// Run afterEach hook (always, even on failure)
|
|
190
|
+
if (hooks?.afterEach) {
|
|
191
|
+
try {
|
|
192
|
+
await hooks.afterEach(instance);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* best effort */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const duration = Date.now() - start;
|
|
202
|
+
// If the photon failed to load and the test also failed,
|
|
203
|
+
// report as skipped (the test couldn't run due to missing config)
|
|
204
|
+
if (loadError) {
|
|
205
|
+
return {
|
|
206
|
+
photon: photonName,
|
|
207
|
+
test: testName,
|
|
208
|
+
passed: true,
|
|
209
|
+
skipped: true,
|
|
210
|
+
duration,
|
|
211
|
+
message: `Photon not configured: ${loadError.split('\n')[0]}`,
|
|
212
|
+
mode: 'direct',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const failResult = {
|
|
216
|
+
photon: photonName,
|
|
217
|
+
test: testName,
|
|
218
|
+
passed: false,
|
|
219
|
+
duration,
|
|
220
|
+
error: error.message || String(error),
|
|
221
|
+
mode: 'direct',
|
|
222
|
+
};
|
|
223
|
+
failResult.issueUrl = generateIssueUrl(failResult, workingDir);
|
|
224
|
+
return failResult;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Run a sequence test: all steps share one photon instance.
|
|
229
|
+
*/
|
|
230
|
+
async function runExternalSequence(steps, photonPath, photonName, sequenceName, workingDir) {
|
|
231
|
+
const results = [];
|
|
232
|
+
try {
|
|
233
|
+
const loader = new PhotonLoader(false);
|
|
234
|
+
const loaded = await loader.loadFile(photonPath);
|
|
235
|
+
const instance = loaded.instance;
|
|
236
|
+
for (const step of steps) {
|
|
237
|
+
const stepTestName = `${sequenceName}/${step.name}`;
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
try {
|
|
240
|
+
await step.fn(instance);
|
|
241
|
+
results.push({
|
|
242
|
+
photon: photonName,
|
|
243
|
+
test: stepTestName,
|
|
244
|
+
passed: true,
|
|
245
|
+
duration: Date.now() - start,
|
|
246
|
+
mode: 'direct',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const failResult = {
|
|
251
|
+
photon: photonName,
|
|
252
|
+
test: stepTestName,
|
|
253
|
+
passed: false,
|
|
254
|
+
duration: Date.now() - start,
|
|
255
|
+
error: error.message || String(error),
|
|
256
|
+
mode: 'direct',
|
|
257
|
+
};
|
|
258
|
+
failResult.issueUrl = generateIssueUrl(failResult, workingDir);
|
|
259
|
+
results.push(failResult);
|
|
260
|
+
// Abort remaining steps on failure
|
|
261
|
+
for (const remaining of steps.slice(steps.indexOf(step) + 1)) {
|
|
262
|
+
results.push({
|
|
263
|
+
photon: photonName,
|
|
264
|
+
test: `${sequenceName}/${remaining.name}`,
|
|
265
|
+
passed: false,
|
|
266
|
+
skipped: true,
|
|
267
|
+
duration: 0,
|
|
268
|
+
message: 'Skipped (previous step failed)',
|
|
269
|
+
mode: 'direct',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
results.push({
|
|
278
|
+
photon: photonName,
|
|
279
|
+
test: `${sequenceName}/*`,
|
|
280
|
+
passed: false,
|
|
281
|
+
duration: 0,
|
|
282
|
+
error: `Failed to load photon: ${error.message}`,
|
|
283
|
+
mode: 'direct',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Run all external tests from a .test.ts file.
|
|
290
|
+
*/
|
|
291
|
+
async function runExternalTests(testFilePath, photonPath, photonName, workingDir, specificTest) {
|
|
292
|
+
const discovered = await discoverExternalTests(testFilePath);
|
|
293
|
+
if (!discovered || discovered.tests.length === 0)
|
|
294
|
+
return [];
|
|
295
|
+
const { tests, hooks } = discovered;
|
|
296
|
+
const results = [];
|
|
297
|
+
// Filter by specific test name if provided
|
|
298
|
+
let testsToRun = specificTest
|
|
299
|
+
? tests.filter((t) => t.name === specificTest || t.name === `test${specificTest}`)
|
|
300
|
+
: tests;
|
|
301
|
+
// Handle @only: if any test has @only, run only those
|
|
302
|
+
const onlyTests = testsToRun.filter((t) => t.only);
|
|
303
|
+
if (onlyTests.length > 0) {
|
|
304
|
+
testsToRun = onlyTests;
|
|
305
|
+
}
|
|
306
|
+
// Run beforeAll hook (with a temporary instance)
|
|
307
|
+
if (hooks.beforeAll) {
|
|
308
|
+
try {
|
|
309
|
+
const loader = new PhotonLoader(false);
|
|
310
|
+
const loaded = await loader.loadFile(photonPath);
|
|
311
|
+
await hooks.beforeAll(loaded.instance);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// beforeAll may fail if photon can't load (missing config) —
|
|
315
|
+
// individual tests will handle this gracefully via stub instance
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const test of testsToRun) {
|
|
319
|
+
// Handle @skip
|
|
320
|
+
if (test.skip) {
|
|
321
|
+
results.push({
|
|
322
|
+
photon: photonName,
|
|
323
|
+
test: test.name,
|
|
324
|
+
passed: true,
|
|
325
|
+
skipped: true,
|
|
326
|
+
duration: 0,
|
|
327
|
+
message: typeof test.skip === 'string' ? test.skip : 'Skipped',
|
|
328
|
+
mode: 'direct',
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (test.steps) {
|
|
333
|
+
// Sequence test
|
|
334
|
+
const seqResults = await runExternalSequence(test.steps, photonPath, photonName, test.name, workingDir);
|
|
335
|
+
results.push(...seqResults);
|
|
336
|
+
}
|
|
337
|
+
else if (test.fn) {
|
|
338
|
+
// Regular test
|
|
339
|
+
const result = await runExternalTest(test.fn, photonPath, photonName, test.name, workingDir, hooks);
|
|
340
|
+
results.push(result);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Run afterAll hook
|
|
344
|
+
if (hooks.afterAll) {
|
|
345
|
+
try {
|
|
346
|
+
const loader = new PhotonLoader(false);
|
|
347
|
+
const loaded = await loader.loadFile(photonPath);
|
|
348
|
+
await hooks.afterAll(loaded.instance);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// afterAll may fail if photon can't load — ignore gracefully
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return results;
|
|
355
|
+
}
|
|
356
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
26
357
|
// ISSUE URL GENERATOR
|
|
27
358
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
28
359
|
const ISSUE_REPO = 'https://github.com/anthropics/photon';
|
|
@@ -421,137 +752,142 @@ async function runMcpTest(photonPath, photonName, methodName, params, workingDir
|
|
|
421
752
|
*/
|
|
422
753
|
async function runPhotonTests(photonPath, photonName, workingDir, mode, specificTest) {
|
|
423
754
|
const results = [];
|
|
755
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
756
|
+
// EXTERNAL .test.ts FILE (preferred — runs first, creates own instances)
|
|
757
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
758
|
+
let hasExternalTests = false;
|
|
759
|
+
if (mode === 'direct' || mode === 'all') {
|
|
760
|
+
const testFilePath = resolveTestFilePath(photonPath);
|
|
761
|
+
if (testFilePath) {
|
|
762
|
+
hasExternalTests = true;
|
|
763
|
+
const externalResults = await runExternalTests(testFilePath, photonPath, photonName, workingDir, specificTest);
|
|
764
|
+
results.push(...externalResults);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
768
|
+
// INLINE test* METHODS (legacy — skipped when external tests exist)
|
|
769
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
770
|
+
// Load the photon instance (needed for inline tests and CLI/MCP tests)
|
|
424
771
|
const loader = new PhotonLoader(false);
|
|
772
|
+
let instance = null;
|
|
425
773
|
try {
|
|
426
774
|
const photon = await loader.loadFile(photonPath);
|
|
427
|
-
|
|
428
|
-
|
|
775
|
+
instance = photon.instance;
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
// If external tests already ran, load failure is fine (they create own instances)
|
|
779
|
+
if (!hasExternalTests && (mode === 'direct' || mode === 'all')) {
|
|
429
780
|
return [
|
|
430
781
|
{
|
|
431
782
|
photon: photonName,
|
|
432
783
|
test: '*',
|
|
433
784
|
passed: false,
|
|
434
785
|
duration: 0,
|
|
435
|
-
error:
|
|
786
|
+
error: `Failed to load photon: ${error.message}`,
|
|
436
787
|
mode: 'direct',
|
|
437
788
|
},
|
|
438
789
|
];
|
|
439
790
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
791
|
+
}
|
|
792
|
+
if (!hasExternalTests && (mode === 'direct' || mode === 'all') && instance) {
|
|
793
|
+
const testMethods = getTestMethods(instance);
|
|
794
|
+
if (testMethods.length > 0) {
|
|
795
|
+
// Filter to specific test if requested
|
|
796
|
+
const testsToRun = specificTest
|
|
797
|
+
? testMethods.filter((t) => t === specificTest || t === `test${specificTest}`)
|
|
798
|
+
: testMethods;
|
|
799
|
+
if (specificTest && testsToRun.length === 0 && mode === 'direct') {
|
|
800
|
+
return [
|
|
801
|
+
{
|
|
802
|
+
photon: photonName,
|
|
803
|
+
test: specificTest,
|
|
804
|
+
passed: false,
|
|
805
|
+
duration: 0,
|
|
806
|
+
error: `Test not found: ${specificTest}`,
|
|
807
|
+
mode: 'direct',
|
|
808
|
+
},
|
|
809
|
+
];
|
|
810
|
+
}
|
|
811
|
+
// Run testBeforeAll if it exists
|
|
812
|
+
if (hasLifecycleHook(instance, 'testBeforeAll')) {
|
|
813
|
+
try {
|
|
814
|
+
await instance.testBeforeAll();
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
451
817
|
return [
|
|
452
818
|
{
|
|
453
819
|
photon: photonName,
|
|
454
|
-
test:
|
|
820
|
+
test: 'beforeAll',
|
|
455
821
|
passed: false,
|
|
456
822
|
duration: 0,
|
|
457
|
-
error: `
|
|
823
|
+
error: `Setup failed: ${error.message}`,
|
|
458
824
|
mode: 'direct',
|
|
459
825
|
},
|
|
460
826
|
];
|
|
461
827
|
}
|
|
462
|
-
// Run testBeforeAll if it exists
|
|
463
|
-
if (hasLifecycleHook(instance, 'testBeforeAll')) {
|
|
464
|
-
try {
|
|
465
|
-
await instance.testBeforeAll();
|
|
466
|
-
}
|
|
467
|
-
catch (error) {
|
|
468
|
-
return [
|
|
469
|
-
{
|
|
470
|
-
photon: photonName,
|
|
471
|
-
test: 'beforeAll',
|
|
472
|
-
passed: false,
|
|
473
|
-
duration: 0,
|
|
474
|
-
error: `Setup failed: ${error.message}`,
|
|
475
|
-
mode: 'direct',
|
|
476
|
-
},
|
|
477
|
-
];
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
// Run direct tests
|
|
481
|
-
for (const testName of testsToRun) {
|
|
482
|
-
const result = await runDirectTest(instance, photonName, testName, workingDir);
|
|
483
|
-
results.push(result);
|
|
484
|
-
}
|
|
485
|
-
// Run testAfterAll if it exists
|
|
486
|
-
if (hasLifecycleHook(instance, 'testAfterAll')) {
|
|
487
|
-
try {
|
|
488
|
-
await instance.testAfterAll();
|
|
489
|
-
}
|
|
490
|
-
catch (error) {
|
|
491
|
-
results.push({
|
|
492
|
-
photon: photonName,
|
|
493
|
-
test: 'afterAll',
|
|
494
|
-
passed: false,
|
|
495
|
-
duration: 0,
|
|
496
|
-
error: `Teardown failed: ${error.message}`,
|
|
497
|
-
mode: 'direct',
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
828
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
506
|
-
if (mode === 'cli' || mode === 'all') {
|
|
507
|
-
const methods = await getPublicMethods(photonPath);
|
|
508
|
-
for (const method of methods) {
|
|
509
|
-
// Skip test methods and lifecycle hooks in interface tests
|
|
510
|
-
if (method.name.startsWith('test') || method.name.startsWith('on')) {
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
// Skip if specific test requested and doesn't match
|
|
514
|
-
if (specificTest && !`cli:${method.name}`.includes(specificTest)) {
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
const params = buildExampleParams(method);
|
|
518
|
-
const result = await runCliTest(photonName, method.name, params, workingDir);
|
|
829
|
+
// Run direct tests
|
|
830
|
+
for (const testName of testsToRun) {
|
|
831
|
+
const result = await runDirectTest(instance, photonName, testName, workingDir);
|
|
519
832
|
results.push(result);
|
|
520
833
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (mode === 'mcp' || mode === 'all') {
|
|
526
|
-
const methods = await getPublicMethods(photonPath);
|
|
527
|
-
for (const method of methods) {
|
|
528
|
-
// Skip test methods and lifecycle hooks in interface tests
|
|
529
|
-
if (method.name.startsWith('test') || method.name.startsWith('on')) {
|
|
530
|
-
continue;
|
|
834
|
+
// Run testAfterAll if it exists
|
|
835
|
+
if (hasLifecycleHook(instance, 'testAfterAll')) {
|
|
836
|
+
try {
|
|
837
|
+
await instance.testAfterAll();
|
|
531
838
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
839
|
+
catch (error) {
|
|
840
|
+
results.push({
|
|
841
|
+
photon: photonName,
|
|
842
|
+
test: 'afterAll',
|
|
843
|
+
passed: false,
|
|
844
|
+
duration: 0,
|
|
845
|
+
error: `Teardown failed: ${error.message}`,
|
|
846
|
+
mode: 'direct',
|
|
847
|
+
});
|
|
535
848
|
}
|
|
536
|
-
const params = buildExampleParams(method);
|
|
537
|
-
const result = await runMcpTest(photonPath, photonName, method.name, params, workingDir);
|
|
538
|
-
results.push(result);
|
|
539
849
|
}
|
|
540
850
|
}
|
|
541
|
-
return results;
|
|
542
851
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
852
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
853
|
+
// CLI INTERFACE TESTS
|
|
854
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
855
|
+
if (mode === 'cli' || mode === 'all') {
|
|
856
|
+
const methods = await getPublicMethods(photonPath);
|
|
857
|
+
for (const method of methods) {
|
|
858
|
+
// Skip test methods and lifecycle hooks in interface tests
|
|
859
|
+
if (method.name.startsWith('test') || method.name.startsWith('on')) {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
// Skip if specific test requested and doesn't match
|
|
863
|
+
if (specificTest && !`cli:${method.name}`.includes(specificTest)) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const params = buildExampleParams(method);
|
|
867
|
+
const result = await runCliTest(photonName, method.name, params, workingDir);
|
|
868
|
+
results.push(result);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
872
|
+
// MCP INTERFACE TESTS
|
|
873
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
874
|
+
if (mode === 'mcp' || mode === 'all') {
|
|
875
|
+
const methods = await getPublicMethods(photonPath);
|
|
876
|
+
for (const method of methods) {
|
|
877
|
+
// Skip test methods and lifecycle hooks in interface tests
|
|
878
|
+
if (method.name.startsWith('test') || method.name.startsWith('on')) {
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
// Skip if specific test requested and doesn't match
|
|
882
|
+
if (specificTest && !`mcp:${method.name}`.includes(specificTest)) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const params = buildExampleParams(method);
|
|
886
|
+
const result = await runMcpTest(photonPath, photonName, method.name, params, workingDir);
|
|
887
|
+
results.push(result);
|
|
888
|
+
}
|
|
554
889
|
}
|
|
890
|
+
return results;
|
|
555
891
|
}
|
|
556
892
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
557
893
|
// OUTPUT FORMATTING
|
|
@@ -641,6 +977,39 @@ function printSummary(summary) {
|
|
|
641
977
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
642
978
|
// PUBLIC API
|
|
643
979
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
980
|
+
/**
|
|
981
|
+
* List all tests for a photon (external + inline).
|
|
982
|
+
* Used by Beam UI to discover available tests.
|
|
983
|
+
*/
|
|
984
|
+
export async function listTests(photonPath, instance) {
|
|
985
|
+
const tests = [];
|
|
986
|
+
// External .test.ts tests
|
|
987
|
+
const testFilePath = resolveTestFilePath(photonPath);
|
|
988
|
+
if (testFilePath) {
|
|
989
|
+
const discovered = await discoverExternalTests(testFilePath);
|
|
990
|
+
if (discovered) {
|
|
991
|
+
for (const t of discovered.tests) {
|
|
992
|
+
if (t.steps) {
|
|
993
|
+
// Sequence: list each step
|
|
994
|
+
for (const step of t.steps) {
|
|
995
|
+
tests.push({ name: `${t.name}/${step.name}`, source: 'external', skip: t.skip });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
tests.push({ name: t.name, source: 'external', skip: t.skip });
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Inline test* methods
|
|
1005
|
+
if (instance) {
|
|
1006
|
+
const inlineMethods = getTestMethods(instance);
|
|
1007
|
+
for (const name of inlineMethods) {
|
|
1008
|
+
tests.push({ name, source: 'inline' });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return tests;
|
|
1012
|
+
}
|
|
644
1013
|
/**
|
|
645
1014
|
* Main test runner
|
|
646
1015
|
*/
|
|
@@ -648,16 +1017,31 @@ export async function runTests(workingDir, photonName, testName, options = {}) {
|
|
|
648
1017
|
const startTime = Date.now();
|
|
649
1018
|
const results = [];
|
|
650
1019
|
const mode = options.mode || 'direct';
|
|
1020
|
+
// Also scan CWD for photon files (development repos)
|
|
1021
|
+
const cwd = process.cwd();
|
|
1022
|
+
const cwdIsDifferent = path.resolve(cwd) !== path.resolve(workingDir);
|
|
651
1023
|
if (!options.json) {
|
|
652
1024
|
console.log('');
|
|
653
1025
|
console.log(chalk.bold('⚡ Photon Test Runner'));
|
|
654
1026
|
console.log(chalk.gray(` ${workingDir}`));
|
|
1027
|
+
if (cwdIsDifferent) {
|
|
1028
|
+
console.log(chalk.gray(` ${cwd} ${chalk.cyan('(dev)')}`));
|
|
1029
|
+
}
|
|
655
1030
|
console.log(chalk.gray(` Mode: ${mode}`));
|
|
656
1031
|
console.log('');
|
|
657
1032
|
}
|
|
658
1033
|
if (photonName) {
|
|
659
|
-
// Run tests for specific photon
|
|
660
|
-
|
|
1034
|
+
// Run tests for specific photon — check CWD first, then installed dir
|
|
1035
|
+
let photonPath = null;
|
|
1036
|
+
if (cwdIsDifferent) {
|
|
1037
|
+
const cwdCandidate = path.join(cwd, `${photonName}.photon.ts`);
|
|
1038
|
+
if (existsSync(cwdCandidate)) {
|
|
1039
|
+
photonPath = cwdCandidate;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (!photonPath) {
|
|
1043
|
+
photonPath = await resolvePhotonPath(photonName, workingDir);
|
|
1044
|
+
}
|
|
661
1045
|
if (!photonPath) {
|
|
662
1046
|
logger.error(`Photon not found: ${photonName}`);
|
|
663
1047
|
process.exit(1);
|
|
@@ -675,11 +1059,32 @@ export async function runTests(workingDir, photonName, testName, options = {}) {
|
|
|
675
1059
|
}
|
|
676
1060
|
}
|
|
677
1061
|
else {
|
|
678
|
-
// Run tests for all photons
|
|
679
|
-
const
|
|
680
|
-
|
|
1062
|
+
// Run tests for all photons — merge installed + CWD photons
|
|
1063
|
+
const installedPhotons = await listPhotonMCPs(workingDir);
|
|
1064
|
+
// Build a map of photon name → path, CWD photons take priority
|
|
1065
|
+
const photonMap = new Map();
|
|
1066
|
+
for (const name of installedPhotons) {
|
|
1067
|
+
const p = path.join(workingDir, `${name}.photon.ts`);
|
|
1068
|
+
if (existsSync(p)) {
|
|
1069
|
+
photonMap.set(name, p);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Discover photons in CWD (development repos)
|
|
1073
|
+
if (cwdIsDifferent && existsSync(cwd)) {
|
|
1074
|
+
try {
|
|
1075
|
+
const cwdFiles = readdirSync(cwd).filter((f) => f.endsWith('.photon.ts'));
|
|
1076
|
+
for (const file of cwdFiles) {
|
|
1077
|
+
const name = file.replace(/\.photon\.ts$/, '');
|
|
1078
|
+
photonMap.set(name, path.join(cwd, file)); // CWD overrides installed
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
// Ignore CWD read errors
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (photonMap.size === 0) {
|
|
681
1086
|
if (!options.json) {
|
|
682
|
-
console.log(chalk.yellow('No photons found
|
|
1087
|
+
console.log(chalk.yellow('No photons found'));
|
|
683
1088
|
}
|
|
684
1089
|
return {
|
|
685
1090
|
total: 0,
|
|
@@ -691,23 +1096,25 @@ export async function runTests(workingDir, photonName, testName, options = {}) {
|
|
|
691
1096
|
mode,
|
|
692
1097
|
};
|
|
693
1098
|
}
|
|
694
|
-
for (const photon of
|
|
695
|
-
const photonPath = path.join(workingDir, `${photon}.photon.ts`);
|
|
1099
|
+
for (const [photon, photonPath] of photonMap) {
|
|
696
1100
|
if (!existsSync(photonPath)) {
|
|
697
1101
|
continue;
|
|
698
1102
|
}
|
|
699
|
-
// Check if photon has any
|
|
1103
|
+
// Check if photon has any tests (external .test.ts or inline test* methods)
|
|
700
1104
|
if (mode === 'direct') {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1105
|
+
const hasExternalTests = resolveTestFilePath(photonPath) !== null;
|
|
1106
|
+
if (!hasExternalTests) {
|
|
1107
|
+
const loader = new PhotonLoader(false);
|
|
1108
|
+
try {
|
|
1109
|
+
const loaded = await loader.loadFile(photonPath);
|
|
1110
|
+
const testMethods = getTestMethods(loaded.instance);
|
|
1111
|
+
if (testMethods.length === 0) {
|
|
1112
|
+
continue; // Skip photons with no tests in direct mode
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
continue;
|
|
707
1117
|
}
|
|
708
|
-
}
|
|
709
|
-
catch {
|
|
710
|
-
continue;
|
|
711
1118
|
}
|
|
712
1119
|
}
|
|
713
1120
|
if (!options.json) {
|