proteum 2.4.4 → 2.5.1

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 (79) hide show
  1. package/README.md +81 -52
  2. package/agents/project/AGENTS.md +112 -31
  3. package/agents/project/CODING_STYLE.md +2 -2
  4. package/agents/project/app-root/AGENTS.md +1 -3
  5. package/agents/project/client/AGENTS.md +5 -1
  6. package/agents/project/client/pages/AGENTS.md +21 -9
  7. package/agents/project/diagnostics.md +2 -2
  8. package/agents/project/optimizations.md +1 -1
  9. package/agents/project/root/AGENTS.md +105 -22
  10. package/agents/project/server/routes/AGENTS.md +30 -1
  11. package/agents/project/server/services/AGENTS.md +4 -0
  12. package/agents/project/tests/AGENTS.md +1 -1
  13. package/cli/commands/doctor.ts +54 -3
  14. package/cli/commands/runtime.ts +6 -0
  15. package/cli/commands/worktree.ts +116 -0
  16. package/cli/compiler/artifacts/controllers.ts +16 -15
  17. package/cli/compiler/artifacts/discovery.ts +129 -17
  18. package/cli/compiler/artifacts/routing.ts +0 -5
  19. package/cli/compiler/artifacts/services.ts +253 -76
  20. package/cli/compiler/common/controllers.ts +159 -57
  21. package/cli/compiler/common/generatedRouteModules.ts +457 -363
  22. package/cli/mcp/router.ts +47 -3
  23. package/cli/presentation/commands.ts +25 -15
  24. package/cli/runtime/commands.ts +39 -12
  25. package/cli/runtime/worktreeBootstrap.ts +608 -0
  26. package/cli/scaffold/index.ts +28 -18
  27. package/cli/scaffold/templates.ts +44 -33
  28. package/cli/utils/agents.ts +14 -1
  29. package/client/app/index.ts +22 -5
  30. package/client/services/router/index.tsx +23 -3
  31. package/client/services/router/request/api.ts +16 -6
  32. package/common/dev/contractsDoctor.ts +1 -1
  33. package/common/dev/mcpPayloads.ts +8 -1
  34. package/common/env/proteumEnv.ts +14 -2
  35. package/common/router/contracts.ts +1 -1
  36. package/common/router/definitions.ts +177 -0
  37. package/common/router/index.ts +23 -12
  38. package/common/router/pageData.ts +5 -5
  39. package/common/router/register.ts +2 -2
  40. package/common/router/request/api.ts +12 -2
  41. package/docs/agent-routing.md +5 -2
  42. package/docs/diagnostics.md +2 -0
  43. package/docs/mcp.md +6 -3
  44. package/docs/migration-2.5.md +226 -0
  45. package/eslint.js +89 -42
  46. package/package.json +1 -1
  47. package/server/app/commands.ts +5 -1
  48. package/server/app/container/console/index.ts +1 -1
  49. package/server/app/controller/index.ts +98 -40
  50. package/server/app/index.ts +120 -3
  51. package/server/app/service/index.ts +5 -1
  52. package/server/index.ts +6 -2
  53. package/server/services/router/index.ts +50 -41
  54. package/server/services/router/response/index.ts +2 -2
  55. package/tests/agents-utils.test.cjs +14 -1
  56. package/tests/cli-mcp-command.test.cjs +84 -0
  57. package/tests/client-app-error-handling.test.cjs +100 -0
  58. package/tests/definition-contracts.test.cjs +453 -0
  59. package/tests/dev-transpile-watch.test.cjs +37 -31
  60. package/tests/eslint-rules.test.cjs +185 -8
  61. package/tests/mcp.test.cjs +90 -0
  62. package/tests/scaffold-templates.test.cjs +18 -0
  63. package/tests/server-app-report-error.test.cjs +135 -0
  64. package/tests/worktree-bootstrap.test.cjs +206 -0
  65. package/types/aliases.d.ts +0 -5
  66. package/types/controller-input.test.ts +23 -17
  67. package/types/controller-request-context.test.ts +10 -11
  68. package/cli/commands/migrate.ts +0 -51
  69. package/cli/migrate/pageContract.ts +0 -516
  70. package/docs/migrate-from-2.1.3.md +0 -396
  71. package/scripts/cleanup-generated-controllers.ts +0 -62
  72. package/scripts/fix-reference-app-typing.ts +0 -490
  73. package/scripts/format-router-registrations.ts +0 -119
  74. package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
  75. package/scripts/refactor-client-app-imports.ts +0 -244
  76. package/scripts/refactor-client-pages.ts +0 -587
  77. package/scripts/refactor-server-controllers.ts +0 -471
  78. package/scripts/refactor-server-runtime-aliases.ts +0 -360
  79. package/scripts/restore-client-app-import-files.ts +0 -41
@@ -370,24 +370,26 @@ ${cacheConfigSource}
370
370
  );
371
371
  writeFile(
372
372
  path.join(appRoot, 'server', 'index.ts'),
373
- `import { Application } from '@server/app';
373
+ `import { defineApplication } from '@server/app';
374
374
  import Router from '@server/services/router';
375
375
  import SchemaRouter from '@server/services/schema/router';
376
376
 
377
377
  import * as appConfig from '@/server/config/app';
378
378
 
379
- export default class TranspileWatchFixture extends Application {
380
- public Router = new Router(
381
- this,
382
- {
383
- ...appConfig.routerBaseConfig,
384
- plugins: {
385
- schema: new SchemaRouter({}, this),
379
+ export default defineApplication({
380
+ services: () => ({}),
381
+ router: (app) =>
382
+ new Router(
383
+ app,
384
+ {
385
+ ...appConfig.routerBaseConfig,
386
+ plugins: {
387
+ schema: new SchemaRouter({}, app),
388
+ },
386
389
  },
387
- },
388
- this,
389
- );
390
- }
390
+ app,
391
+ ),
392
+ });
391
393
  `,
392
394
  );
393
395
  writeFile(
@@ -403,56 +405,53 @@ export default class TranspileWatchClient extends ClientApplication {
403
405
 
404
406
  public boot() {}
405
407
  public handleUpdate() {}
406
- public handleError(error: Error) {
407
- throw error;
408
- }
409
408
  }
410
409
  `,
411
410
  );
412
411
  writeFile(
413
412
  path.join(appRoot, 'client', 'pages', 'index.tsx'),
414
- `import Router from '@/client/router';
413
+ `import { definePageRoute } from '@common/router/definitions';
415
414
  import { SharedMarker } from '@test/shared';
416
415
 
417
- Router.page(
418
- '/',
419
- {
416
+ export default definePageRoute({
417
+ path: '/',
418
+ options: {
420
419
  auth: false,
421
420
  layout: false,
422
421
  },
423
- null,
424
- () => {
422
+ data: null,
423
+ render: () => {
425
424
  return (
426
425
  <main>
427
426
  <SharedMarker />
428
427
  </main>
429
428
  );
430
429
  },
431
- );
430
+ });
432
431
  `,
433
432
  );
434
433
  if (options.staticPage) {
435
434
  writeFile(
436
435
  path.join(appRoot, 'client', 'pages', 'static-cache.tsx'),
437
- `import Router from '@/client/router';
436
+ `import { definePageRoute } from '@common/router/definitions';
438
437
  import { SharedMarker } from '@test/shared';
439
438
 
440
- Router.page(
441
- '/static-cache',
442
- {
439
+ export default definePageRoute({
440
+ path: '/static-cache',
441
+ options: {
443
442
  auth: false,
444
443
  layout: false,
445
444
  static: { urls: ['/static-cache'] },
446
445
  },
447
- null,
448
- () => {
446
+ data: null,
447
+ render: () => {
449
448
  return (
450
449
  <main>
451
450
  <SharedMarker />
452
451
  </main>
453
452
  );
454
453
  },
455
- );
454
+ });
456
455
  `,
457
456
  );
458
457
  }
@@ -539,8 +538,13 @@ test('proteum dev invalidates client assets and reloads for transpiled package s
539
538
 
540
539
  try {
541
540
  await waitForSessionReady(sessionFile, child, getOutput);
541
+ await request(port, '/', { Accept: 'text/html' });
542
542
 
543
- const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
543
+ const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL').catch(
544
+ (error) => {
545
+ throw new Error(`${error.message}\n${getOutput()}`);
546
+ },
547
+ );
544
548
  const initialScriptContent = fs.readFileSync(initialScriptAsset, 'utf8');
545
549
  const scriptReloadStream = await connectToReloadStream(port + 1);
546
550
 
@@ -608,7 +612,9 @@ test('proteum dev applies router HTTP cache config to HTML and public assets', {
608
612
  const staticResponse = await waitForHeader(port, '/static-cache', 'cache-control', staticHtmlCacheControl);
609
613
  assert.equal(staticResponse.headers.get('surrogate-control'), 'static-surrogate');
610
614
 
611
- const asset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
615
+ const asset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL').catch((error) => {
616
+ throw new Error(`${error.message}\n${getOutput()}`);
617
+ });
612
618
  const { response: assetResponse } = await request(port, toPublicAssetUrl(appRoot, asset));
613
619
 
614
620
  assert.equal(assetResponse.headers.get('cache-control'), publicAssetCacheControl);
@@ -3,14 +3,25 @@ const { Linter } = require('eslint');
3
3
 
4
4
  const { createProteumEslintConfig } = require('../eslint.js');
5
5
 
6
- const lint = (code) => {
6
+ const lint = (code, filename = 'client/example.tsx') => {
7
7
  const linter = new Linter({ configType: 'flat' });
8
8
  return linter.verify(code, createProteumEslintConfig(), {
9
- filename: 'client/example.tsx',
9
+ filename,
10
10
  });
11
11
  };
12
12
 
13
13
  const swallowedErrorRuleId = 'proteum/no-swallowed-caught-error';
14
+ const noAppImportRuleId = 'proteum/no-app-import';
15
+
16
+ test('proteum lint rejects contextual @app imports', () => {
17
+ const messages = lint(`
18
+ import { Router } from '@app';
19
+
20
+ export const route = Router;
21
+ `);
22
+
23
+ assert.equal(messages.some((message) => message.ruleId === noAppImportRuleId), true);
24
+ });
14
25
 
15
26
  test('proteum lint rejects empty catch blocks', () => {
16
27
  const messages = lint(`
@@ -50,6 +61,32 @@ test('proteum lint rejects generic catch feedback that drops original error deta
50
61
  assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
51
62
  });
52
63
 
64
+ test('proteum lint rejects console calls as caught error preservation', () => {
65
+ for (const method of ['error', 'warn']) {
66
+ const messages = lint(`
67
+ export const run = async () => {
68
+ try {
69
+ await Investor.api.getDashboard();
70
+ } catch (error) {
71
+ console.${method}(error);
72
+ }
73
+ };
74
+ `);
75
+
76
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
77
+ }
78
+ });
79
+
80
+ test('proteum lint rejects direct console promise catch handlers', () => {
81
+ const messages = lint(`
82
+ export const run = () => {
83
+ Investor.api.ensureApiKey().catch(console.log);
84
+ };
85
+ `);
86
+
87
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
88
+ });
89
+
53
90
  test('proteum lint allows rethrowing the caught error', () => {
54
91
  const messages = lint(`
55
92
  export const run = async () => {
@@ -65,7 +102,7 @@ test('proteum lint allows rethrowing the caught error', () => {
65
102
  assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
66
103
  });
67
104
 
68
- test('proteum lint allows surfacing original error details', () => {
105
+ test('proteum lint rejects user feedback that does not route the caught error', () => {
69
106
  const messages = lint(`
70
107
  export const run = async () => {
71
108
  try {
@@ -78,10 +115,10 @@ test('proteum lint allows surfacing original error details', () => {
78
115
  };
79
116
  `);
80
117
 
81
- assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
118
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
82
119
  });
83
120
 
84
- test('proteum lint allows surfacing a message derived from the caught error', () => {
121
+ test('proteum lint rejects derived message state that does not route the caught error', () => {
85
122
  const messages = lint(`
86
123
  export const run = async () => {
87
124
  try {
@@ -93,17 +130,157 @@ test('proteum lint allows surfacing a message derived from the caught error', ()
93
130
  };
94
131
  `);
95
132
 
96
- assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
133
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
97
134
  });
98
135
 
99
- test('proteum lint allows routing promise failures to app error handling', () => {
136
+ test('proteum lint allows client catches routed to context app error handling', () => {
100
137
  const messages = lint(`
101
138
  export const run = () => {
139
+ const context = useContext();
102
140
  Investor.api.ensureApiKey().catch((error) => {
103
- Router.app.handleError(error);
141
+ context.app.handleError(error);
104
142
  });
105
143
  };
106
144
  `);
107
145
 
108
146
  assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
109
147
  });
148
+
149
+ test('proteum lint allows client catches using app error messages for UI feedback', () => {
150
+ const messages = lint(`
151
+ export const run = async () => {
152
+ const context = useContext();
153
+ try {
154
+ await Investor.api.ensureApiKey();
155
+ } catch (error) {
156
+ setError(context.app.handleError(error, 'Unable to finish this action.'));
157
+ }
158
+ };
159
+ `);
160
+
161
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
162
+ });
163
+
164
+ test('proteum lint allows client catches routed to local app error handling', () => {
165
+ const messages = lint(`
166
+ export const run = async () => {
167
+ const app = useContext();
168
+ try {
169
+ await Investor.api.ensureApiKey();
170
+ } catch (error) {
171
+ app.handleError(error);
172
+ }
173
+ };
174
+ `);
175
+
176
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
177
+ });
178
+
179
+ test('proteum lint allows client catches routed to useContext app error handling', () => {
180
+ const messages = lint(`
181
+ export const run = async () => {
182
+ try {
183
+ await Investor.api.ensureApiKey();
184
+ } catch (error) {
185
+ useContext().app.handleError(error);
186
+ }
187
+ };
188
+ `);
189
+
190
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
191
+ });
192
+
193
+ test('proteum lint rejects bare client error handlers', () => {
194
+ const messages = lint(`
195
+ export const run = async () => {
196
+ try {
197
+ await Investor.api.ensureApiKey();
198
+ } catch (error) {
199
+ handleError(error);
200
+ }
201
+ };
202
+ `);
203
+
204
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
205
+ });
206
+
207
+ test('proteum lint allows server catches routed to app error reporting', () => {
208
+ const messages = lint(
209
+ `
210
+ export const run = async (context) => {
211
+ try {
212
+ await context.services.Worker.run();
213
+ } catch (error) {
214
+ await context.app.reportError(error, context.request);
215
+ }
216
+ };
217
+ `,
218
+ 'server/example.ts',
219
+ );
220
+
221
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
222
+ });
223
+
224
+ test('proteum lint rejects server catches routed to client app error handling', () => {
225
+ const messages = lint(
226
+ `
227
+ export const run = async (context) => {
228
+ try {
229
+ await context.services.Worker.run();
230
+ } catch (error) {
231
+ context.app.handleError(error);
232
+ }
233
+ };
234
+ `,
235
+ 'server/example.ts',
236
+ );
237
+
238
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
239
+ });
240
+
241
+ test('proteum lint rejects raw server error hooks as caught error handling', () => {
242
+ const messages = lint(
243
+ `
244
+ export const run = async (context) => {
245
+ try {
246
+ await context.services.Worker.run();
247
+ } catch (error) {
248
+ await context.app.runHook('error', error, context.request);
249
+ }
250
+ };
251
+ `,
252
+ 'server/example.ts',
253
+ );
254
+
255
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
256
+ });
257
+
258
+ test('proteum lint allows manual promise rejection', () => {
259
+ const messages = lint(
260
+ `
261
+ export const run = (input) =>
262
+ new Promise((resolve, reject) => {
263
+ input.load().catch((error) => {
264
+ reject(error);
265
+ });
266
+ });
267
+ `,
268
+ 'common/example.ts',
269
+ );
270
+
271
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
272
+ });
273
+
274
+ test('proteum lint allows direct reject promise catch handlers', () => {
275
+ const messages = lint(
276
+ `
277
+ export const run = (input) =>
278
+ new Promise((resolve, reject) => {
279
+ input.load().catch(reject);
280
+ });
281
+ `,
282
+ 'common/example.ts',
283
+ );
284
+
285
+ assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
286
+ });
@@ -186,6 +186,14 @@ test('instruction routing promotes triggered full instruction files', () => {
186
186
  );
187
187
  assert.equal(payload.data.fullReadPolicy.default.includes('read-only'), true);
188
188
  assert.equal(payload.data.selected.some((entry) => entry.fullRead === 'full-before-action'), true);
189
+
190
+ const bugFixPayload = resolveInstructionRouting({ appRoot, query: 'fix OAuth redirect bug' });
191
+ assert.equal(
192
+ bugFixPayload.data.selected.some(
193
+ (entry) => entry.file === path.join(appRoot, 'DOCUMENTATION.md') && /Bug fix/.test(entry.reason),
194
+ ),
195
+ true,
196
+ );
189
197
  });
190
198
 
191
199
  test('workflow start payload combines compact runtime, instructions, owner, and duplicate guidance', () => {
@@ -777,6 +785,88 @@ test('machine MCP router resolves offline monorepo app candidates before dev is
777
785
  await server.close();
778
786
  });
779
787
 
788
+ test('machine MCP workflow_start blocks offline unbootstrapped Codex worktrees', async (t) => {
789
+ const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
790
+ const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-worktree-offline-'));
791
+ process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
792
+ t.onTestFinished(() => {
793
+ if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
794
+ else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
795
+ });
796
+
797
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-worktree-repo-'));
798
+ const appRoot = path.join(repoRoot, '.codex', 'worktrees', 'product');
799
+ writeProteumAppFixture(appRoot, { identifier: 'ProductApp', name: 'Product', routerPort: 3020 });
800
+
801
+ const server = createProteumMachineMcpServer({ version: 'test' });
802
+ const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
803
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
804
+
805
+ await server.connect(serverTransport);
806
+ await client.connect(clientTransport);
807
+
808
+ const workflow = await client.callTool({ name: 'workflow_start', arguments: { cwd: appRoot, task: 'runtime health' } });
809
+ const payload = JSON.parse(workflow.content[0].text);
810
+
811
+ assert.equal(payload.ok, false);
812
+ assert.match(payload.summary, /has not completed Proteum worktree bootstrap/);
813
+ assert.equal(payload.nextActions.length, 1);
814
+ assert.match(payload.nextActions[0].command, /proteum worktree init --source <source-app-root>/);
815
+
816
+ await client.close();
817
+ await server.close();
818
+ });
819
+
820
+ test('machine MCP workflow_start blocks live unbootstrapped Codex worktrees before forwarding', async (t) => {
821
+ const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
822
+ const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-worktree-live-'));
823
+ process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
824
+ t.onTestFinished(() => {
825
+ if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
826
+ else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
827
+ });
828
+
829
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-worktree-live-repo-'));
830
+ const appRoot = path.join(repoRoot, '.codex', 'worktrees', 'product');
831
+ writeProteumAppFixture(appRoot, { identifier: 'ProductApp', name: 'Product', routerPort: 3020 });
832
+ await writeMachineDevSessionRecord({
833
+ ...createDevSessionRecord({
834
+ appRoot,
835
+ port: 3020,
836
+ sessionFilePath: path.join(appRoot, 'var/run/proteum/dev/3020.json'),
837
+ }),
838
+ publicUrl: 'http://localhost:3020',
839
+ state: 'ready',
840
+ });
841
+
842
+ let forwarded = false;
843
+ const server = createProteumMachineMcpServer({
844
+ createDevMcpClient: async () => ({
845
+ callTool: async () => {
846
+ forwarded = true;
847
+ throw new Error('should not forward');
848
+ },
849
+ close: async () => {},
850
+ }),
851
+ version: 'test',
852
+ });
853
+ const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
854
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
855
+
856
+ await server.connect(serverTransport);
857
+ await client.connect(clientTransport);
858
+
859
+ const workflow = await client.callTool({ name: 'workflow_start', arguments: { cwd: appRoot, task: 'runtime health' } });
860
+ const payload = JSON.parse(workflow.content[0].text);
861
+
862
+ assert.equal(payload.ok, false);
863
+ assert.equal(forwarded, false);
864
+ assert.match(payload.nextActions[0].command, /proteum worktree init --source <source-app-root>/);
865
+
866
+ await client.close();
867
+ await server.close();
868
+ });
869
+
780
870
  test('machine MCP offline resolution inspects occupied ports before suggesting dev start', async (t) => {
781
871
  const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
782
872
  const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-offline-port-registry-'));
@@ -0,0 +1,18 @@
1
+ const assert = require('node:assert/strict');
2
+ const path = require('node:path');
3
+
4
+ const coreRoot = path.resolve(__dirname, '..');
5
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
6
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
7
+ require('ts-node/register/transpile-only');
8
+
9
+ const { createServerIndexTemplate } = require('../cli/scaffold/templates.ts');
10
+
11
+ test('server index scaffold uses explicit defineApplication router property', () => {
12
+ const content = createServerIndexTemplate({ appIdentifier: 'ExampleApp' });
13
+
14
+ assert.match(content, /export default defineApplication\(\{/);
15
+ assert.match(content, /services: \(\) => \(\{\}\),/);
16
+ assert.match(content, /router: \(app\) =>\s+new Router\(/);
17
+ assert.doesNotMatch(content, /services: \(app\) => \(\{\s+Router: new Router\(/);
18
+ });
@@ -0,0 +1,135 @@
1
+ const assert = require('node:assert/strict');
2
+ const Module = require('node:module');
3
+ const path = require('node:path');
4
+
5
+ const coreRoot = path.join(__dirname, '..');
6
+ require('module-alias').addAliases({
7
+ '@client': path.join(coreRoot, 'client'),
8
+ '@common': path.join(coreRoot, 'common'),
9
+ '@server': path.join(coreRoot, 'server'),
10
+ });
11
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
12
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
13
+ require('ts-node/register/transpile-only');
14
+
15
+ const appContainerStub = {
16
+ Environment: {
17
+ profile: 'test',
18
+ connectedProjects: [],
19
+ },
20
+ Identity: {},
21
+ Setup: {},
22
+ Console: {
23
+ createBugReport: () => {},
24
+ },
25
+ Trace: {
26
+ finishRequest: () => {},
27
+ record: () => {},
28
+ releaseRequest: () => {},
29
+ },
30
+ handleBug: () => {},
31
+ };
32
+
33
+ const applicationModulePath = path.join(coreRoot, 'server/app/index.ts');
34
+ const originalLoad = Module._load;
35
+ Module._load = function patchedLoad(request, parent, isMain) {
36
+ if (parent?.filename === applicationModulePath && request === './container') {
37
+ return { __esModule: true, default: appContainerStub };
38
+ }
39
+
40
+ return originalLoad.call(this, request, parent, isMain);
41
+ };
42
+
43
+ let Application;
44
+ try {
45
+ Application = require('../server/app/index.ts').Application;
46
+ } finally {
47
+ Module._load = originalLoad;
48
+ }
49
+
50
+ class TestApplication extends Application {}
51
+
52
+ const createAppWithHooks = (hooks) => {
53
+ const app = new TestApplication();
54
+ app.hooks = hooks;
55
+
56
+ return app;
57
+ };
58
+
59
+ test('server application reports plain errors through the default error hook', async () => {
60
+ const events = [];
61
+ const request = { id: 'request-1' };
62
+ const app = createAppWithHooks({
63
+ error: [
64
+ async (error, hookRequest) => {
65
+ events.push({
66
+ hook: 'error',
67
+ message: error.message,
68
+ requestId: hookRequest.id,
69
+ });
70
+ },
71
+ ],
72
+ });
73
+
74
+ await app.reportError(new Error('boom'), request);
75
+
76
+ assert.deepEqual(events, [{ hook: 'error', message: 'boom', requestId: 'request-1' }]);
77
+ });
78
+
79
+ test('server application reports HTTP errors through code-specific hooks', async () => {
80
+ const events = [];
81
+ const error = new Error('missing');
82
+ error.http = 404;
83
+ const request = { id: 'request-404' };
84
+ const app = createAppWithHooks({
85
+ 'error.404': [
86
+ async (hookError, hookRequest) => {
87
+ events.push({
88
+ hook: 'error.404',
89
+ message: hookError.message,
90
+ requestId: hookRequest.id,
91
+ });
92
+ },
93
+ ],
94
+ });
95
+
96
+ await app.reportError(error, request);
97
+
98
+ assert.deepEqual(events, [{ hook: 'error.404', message: 'missing', requestId: 'request-404' }]);
99
+ });
100
+
101
+ test('server application reports status errors through code-specific hooks', async () => {
102
+ const events = [];
103
+ const error = new Error('forbidden');
104
+ error.status = 403;
105
+ const app = createAppWithHooks({
106
+ 'error.403': [
107
+ async (hookError) => {
108
+ events.push({
109
+ hook: 'error.403',
110
+ message: hookError.message,
111
+ });
112
+ },
113
+ ],
114
+ });
115
+
116
+ await app.reportError(error);
117
+
118
+ assert.deepEqual(events, [{ hook: 'error.403', message: 'forbidden' }]);
119
+ });
120
+
121
+ test('server application normalizes non-error rejections before reporting', async () => {
122
+ const messages = [];
123
+ const app = createAppWithHooks({
124
+ error: [
125
+ async (error) => {
126
+ messages.push(error.message);
127
+ },
128
+ ],
129
+ });
130
+
131
+ await app.reportError('string failure');
132
+ await app.reportError({ reason: 'unknown failure' });
133
+
134
+ assert.deepEqual(messages, ['string failure', 'Unknown application error']);
135
+ });