@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.
Files changed (56) hide show
  1. package/README.md +81 -72
  2. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/photon-management.js +5 -0
  4. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-browse.d.ts +1 -2
  6. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  7. package/dist/auto-ui/beam/routes/api-browse.js +140 -191
  8. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  10. package/dist/auto-ui/beam/routes/api-config.js +44 -1
  11. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  12. package/dist/auto-ui/beam.d.ts.map +1 -1
  13. package/dist/auto-ui/beam.js +874 -20
  14. package/dist/auto-ui/beam.js.map +1 -1
  15. package/dist/auto-ui/frontend/index.html +83 -60
  16. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  17. package/dist/auto-ui/streamable-http-transport.js +16 -2
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/types.d.ts +1 -1
  20. package/dist/auto-ui/types.d.ts.map +1 -1
  21. package/dist/auto-ui/types.js.map +1 -1
  22. package/dist/beam.bundle.js +2836 -357
  23. package/dist/beam.bundle.js.map +4 -4
  24. package/dist/cli/commands/package-app.d.ts.map +1 -1
  25. package/dist/cli/commands/package-app.js +116 -35
  26. package/dist/cli/commands/package-app.js.map +1 -1
  27. package/dist/context-store.d.ts +5 -0
  28. package/dist/context-store.d.ts.map +1 -1
  29. package/dist/context-store.js +9 -0
  30. package/dist/context-store.js.map +1 -1
  31. package/dist/daemon/server.js +303 -6
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts +21 -0
  34. package/dist/loader.d.ts.map +1 -1
  35. package/dist/loader.js +277 -0
  36. package/dist/loader.js.map +1 -1
  37. package/dist/photon-cli-runner.d.ts.map +1 -1
  38. package/dist/photon-cli-runner.js +21 -1
  39. package/dist/photon-cli-runner.js.map +1 -1
  40. package/dist/photon-doc-extractor.d.ts +6 -0
  41. package/dist/photon-doc-extractor.d.ts.map +1 -1
  42. package/dist/photon-doc-extractor.js +22 -0
  43. package/dist/photon-doc-extractor.js.map +1 -1
  44. package/dist/photons/tunnel.photon.d.ts +5 -9
  45. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  46. package/dist/photons/tunnel.photon.js +36 -96
  47. package/dist/photons/tunnel.photon.js.map +1 -1
  48. package/dist/photons/tunnel.photon.ts +40 -112
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +27 -2
  51. package/dist/server.js.map +1 -1
  52. package/dist/test-runner.d.ts +13 -1
  53. package/dist/test-runner.d.ts.map +1 -1
  54. package/dist/test-runner.js +529 -122
  55. package/dist/test-runner.js.map +1 -1
  56. package/package.json +22 -6
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Photon Test Runner
3
3
  *
4
- * Discovers and runs test* methods in photons
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
- const instance = photon.instance;
428
- if (!instance) {
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: 'Failed to load photon instance',
786
+ error: `Failed to load photon: ${error.message}`,
436
787
  mode: 'direct',
437
788
  },
438
789
  ];
439
790
  }
440
- // ─────────────────────────────────────────────────────────────────────────
441
- // DIRECT TESTS (test* methods)
442
- // ─────────────────────────────────────────────────────────────────────────
443
- if (mode === 'direct' || mode === 'all') {
444
- const testMethods = getTestMethods(instance);
445
- if (testMethods.length > 0) {
446
- // Filter to specific test if requested
447
- const testsToRun = specificTest
448
- ? testMethods.filter((t) => t === specificTest || t === `test${specificTest}`)
449
- : testMethods;
450
- if (specificTest && testsToRun.length === 0 && mode === 'direct') {
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: specificTest,
820
+ test: 'beforeAll',
455
821
  passed: false,
456
822
  duration: 0,
457
- error: `Test not found: ${specificTest}`,
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
- // CLI INTERFACE TESTS
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
- // MCP INTERFACE TESTS
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
- // Skip if specific test requested and doesn't match
533
- if (specificTest && !`mcp:${method.name}`.includes(specificTest)) {
534
- continue;
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
- catch (error) {
544
- return [
545
- {
546
- photon: photonName,
547
- test: '*',
548
- passed: false,
549
- duration: 0,
550
- error: `Failed to load photon: ${error.message}`,
551
- mode: 'direct',
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
- const photonPath = await resolvePhotonPath(photonName, workingDir);
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 photons = await listPhotonMCPs(workingDir);
680
- if (photons.length === 0) {
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 in working directory'));
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 photons) {
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 test methods before printing header (for direct mode)
1103
+ // Check if photon has any tests (external .test.ts or inline test* methods)
700
1104
  if (mode === 'direct') {
701
- const loader = new PhotonLoader(false);
702
- try {
703
- const loaded = await loader.loadFile(photonPath);
704
- const testMethods = getTestMethods(loaded.instance);
705
- if (testMethods.length === 0) {
706
- continue; // Skip photons with no tests in direct mode
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) {