proteum 2.4.3 → 2.5.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 (74) hide show
  1. package/README.md +60 -55
  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 +1 -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/tests/AGENTS.md +1 -1
  12. package/cli/commands/doctor.ts +54 -3
  13. package/cli/commands/runtime.ts +6 -0
  14. package/cli/commands/worktree.ts +116 -0
  15. package/cli/compiler/artifacts/controllers.ts +16 -15
  16. package/cli/compiler/artifacts/discovery.ts +129 -17
  17. package/cli/compiler/artifacts/routing.ts +0 -5
  18. package/cli/compiler/artifacts/services.ts +253 -76
  19. package/cli/compiler/common/controllers.ts +159 -57
  20. package/cli/compiler/common/generatedRouteModules.ts +457 -363
  21. package/cli/mcp/router.ts +47 -3
  22. package/cli/presentation/commands.ts +25 -15
  23. package/cli/runtime/commands.ts +39 -12
  24. package/cli/runtime/worktreeBootstrap.ts +608 -0
  25. package/cli/scaffold/index.ts +28 -18
  26. package/cli/scaffold/templates.ts +44 -33
  27. package/cli/utils/agents.ts +14 -1
  28. package/client/services/router/index.tsx +23 -3
  29. package/client/services/router/request/api.ts +14 -4
  30. package/common/dev/contractsDoctor.ts +1 -1
  31. package/common/dev/mcpPayloads.ts +8 -1
  32. package/common/env/proteumEnv.ts +14 -2
  33. package/common/router/contracts.ts +1 -1
  34. package/common/router/definitions.ts +177 -0
  35. package/common/router/index.ts +23 -12
  36. package/common/router/pageData.ts +5 -5
  37. package/common/router/register.ts +2 -2
  38. package/common/router/request/api.ts +12 -2
  39. package/docs/agent-routing.md +5 -2
  40. package/docs/diagnostics.md +2 -0
  41. package/docs/mcp.md +6 -3
  42. package/eslint.js +36 -1
  43. package/package.json +1 -1
  44. package/server/app/commands.ts +5 -1
  45. package/server/app/container/console/http-client-error-context.test.cjs +10 -1
  46. package/server/app/container/console/index.ts +2 -1
  47. package/server/app/controller/index.ts +98 -40
  48. package/server/app/index.ts +92 -1
  49. package/server/app/service/index.ts +5 -1
  50. package/server/index.ts +6 -2
  51. package/server/services/router/index.ts +47 -38
  52. package/server/services/router/response/index.ts +2 -2
  53. package/tests/agents-utils.test.cjs +14 -1
  54. package/tests/cli-mcp-command.test.cjs +84 -0
  55. package/tests/definition-contracts.test.cjs +453 -0
  56. package/tests/dev-transpile-watch.test.cjs +37 -28
  57. package/tests/eslint-rules.test.cjs +39 -1
  58. package/tests/mcp.test.cjs +90 -0
  59. package/tests/worktree-bootstrap.test.cjs +206 -0
  60. package/types/aliases.d.ts +0 -5
  61. package/types/controller-input.test.ts +23 -17
  62. package/types/controller-request-context.test.ts +10 -11
  63. package/cli/commands/migrate.ts +0 -51
  64. package/cli/migrate/pageContract.ts +0 -516
  65. package/docs/migrate-from-2.1.3.md +0 -396
  66. package/scripts/cleanup-generated-controllers.ts +0 -62
  67. package/scripts/fix-reference-app-typing.ts +0 -490
  68. package/scripts/format-router-registrations.ts +0 -119
  69. package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
  70. package/scripts/refactor-client-app-imports.ts +0 -244
  71. package/scripts/refactor-client-pages.ts +0 -587
  72. package/scripts/refactor-server-controllers.ts +0 -471
  73. package/scripts/refactor-server-runtime-aliases.ts +0 -360
  74. package/scripts/restore-client-app-import-files.ts +0 -41
@@ -0,0 +1,453 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { EventEmitter } = require('node:events');
6
+
7
+ const coreRoot = path.join(__dirname, '..');
8
+ require('module-alias').addAliases({
9
+ '@client': path.join(coreRoot, 'client'),
10
+ '@common': path.join(coreRoot, 'common'),
11
+ '@server': path.join(coreRoot, 'server'),
12
+ });
13
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
14
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
15
+ require('ts-node/register/transpile-only');
16
+
17
+ const {
18
+ indexRouteDefinitions,
19
+ writeGeneratedRouteModule,
20
+ } = require('../cli/compiler/common/generatedRouteModules.ts');
21
+ const { findClientRouteFiles } = require('../cli/compiler/artifacts/discovery.ts');
22
+ const { indexControllers } = require('../cli/compiler/common/controllers.ts');
23
+ const {
24
+ defineAction,
25
+ defineController,
26
+ runControllerAction,
27
+ schema,
28
+ } = require('../server/app/controller/index.ts');
29
+ const Service = require('../server/app/service/index.ts').default;
30
+ const { expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
31
+ const { parseProteumEnvConfig } = require('../common/env/proteumEnv.ts');
32
+
33
+ const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-definition-contracts-'));
34
+
35
+ const writeFile = (filepath, content) => {
36
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
37
+ fs.writeFileSync(filepath, content);
38
+ };
39
+
40
+ const loadServiceArtifactsForAppRoot = (appRoot) => {
41
+ const Module = require('node:module');
42
+ const modulePath = require.resolve('../cli/compiler/artifacts/services.ts');
43
+ const originalLoad = Module._load;
44
+ const appStub = { paths: { root: appRoot }, identity: { identifier: 'TestApp' }, containerServices: [] };
45
+ const cliStub = { paths: { core: { root: coreRoot } } };
46
+
47
+ delete require.cache[modulePath];
48
+ Module._load = function patchedLoad(request, parent, isMain) {
49
+ if (parent?.filename === modulePath && request === '../../app') {
50
+ return { __esModule: true, default: appStub };
51
+ }
52
+ if (parent?.filename === modulePath && request === '../..') {
53
+ return { __esModule: true, default: cliStub };
54
+ }
55
+
56
+ return originalLoad.call(this, request, parent, isMain);
57
+ };
58
+
59
+ try {
60
+ return require(modulePath);
61
+ } finally {
62
+ Module._load = originalLoad;
63
+ }
64
+ };
65
+
66
+ const withProteumEnv = (env, run) => {
67
+ const previous = {};
68
+ for (const key of Object.keys(env)) previous[key] = process.env[key];
69
+
70
+ Object.assign(process.env, env);
71
+
72
+ try {
73
+ return run();
74
+ } finally {
75
+ for (const key of Object.keys(env)) {
76
+ if (previous[key] === undefined) delete process.env[key];
77
+ else process.env[key] = previous[key];
78
+ }
79
+ }
80
+ };
81
+
82
+ test('route indexer reads explicit page definitions with static metadata', () => {
83
+ const root = createTempDir();
84
+ const filepath = path.join(root, 'client/pages/index.tsx');
85
+
86
+ writeFile(
87
+ filepath,
88
+ `import { definePageRoute } from '@common/router/definitions';
89
+
90
+ const routePath = '/demo';
91
+
92
+ export default definePageRoute({
93
+ path: routePath,
94
+ options: { auth: false },
95
+ data: null,
96
+ render: () => null,
97
+ });
98
+ `,
99
+ );
100
+
101
+ const [definition] = indexRouteDefinitions({ side: 'client', sourceFilepath: filepath });
102
+
103
+ assert.equal(definition.methodName, 'page');
104
+ assert.equal(definition.path, '/demo');
105
+ assert.equal(definition.targetResolution, 'static-expression');
106
+ assert.equal(definition.hasData, false);
107
+ assert.deepEqual(definition.normalizedOptionKeys, ['auth']);
108
+ });
109
+
110
+ test('router port override updates absolute runtime URLs', () => {
111
+ const root = createTempDir();
112
+
113
+ const config = withProteumEnv(
114
+ {
115
+ ENV_NAME: 'local',
116
+ ENV_PROFILE: 'dev',
117
+ PORT: '3010',
118
+ URL: 'http://localhost:3010',
119
+ URL_INTERNAL: 'http://localhost:3010',
120
+ },
121
+ () =>
122
+ parseProteumEnvConfig({
123
+ appDir: root,
124
+ routerPortOverride: 3100,
125
+ }),
126
+ );
127
+
128
+ assert.equal(config.router.port, 3100);
129
+ assert.equal(config.router.currentDomain, 'http://localhost:3100');
130
+ assert.equal(config.router.internalUrl, 'http://localhost:3100');
131
+ });
132
+
133
+ test('route wrapper imports explicit definitions without lifting source helpers', () => {
134
+ const root = createTempDir();
135
+ const sourceFilepath = path.join(root, 'client/pages/index.tsx');
136
+ const outputFilepath = path.join(root, '.proteum/client/route-modules/client/pages/index.tsx');
137
+
138
+ writeFile(
139
+ sourceFilepath,
140
+ `import { definePageRoute } from '@common/router/definitions';
141
+
142
+ const helper = 'must stay in the source module';
143
+
144
+ export default definePageRoute({
145
+ path: '/wrapped',
146
+ options: {},
147
+ data: null,
148
+ render: () => helper,
149
+ });
150
+ `,
151
+ );
152
+
153
+ writeGeneratedRouteModule({
154
+ outputFilepath,
155
+ runtime: 'client',
156
+ side: 'client',
157
+ sourceFilepath,
158
+ clientRoute: { chunkId: 'client_pages_index' },
159
+ });
160
+
161
+ const generated = fs.readFileSync(outputFilepath, 'utf8');
162
+
163
+ assert.match(generated, /import __routeDefinition/);
164
+ assert.match(generated, /registerRouteDefinition/);
165
+ assert.doesNotMatch(generated, /must stay in the source module/);
166
+ assert.doesNotMatch(generated, /const \{ Router/);
167
+ });
168
+
169
+ test('route discovery includes explicit route definitions', () => {
170
+ const root = createTempDir();
171
+ const routeFilepath = path.join(root, 'client/pages/index.tsx');
172
+ const helperFilepath = path.join(root, 'client/pages/helpers.tsx');
173
+
174
+ writeFile(
175
+ routeFilepath,
176
+ `import { definePageRoute } from '@common/router/definitions';
177
+
178
+ export default definePageRoute({
179
+ path: '/',
180
+ options: {},
181
+ data: null,
182
+ render: () => null,
183
+ });
184
+ `,
185
+ );
186
+ writeFile(
187
+ helperFilepath,
188
+ `export const helper = true;
189
+ `,
190
+ );
191
+
192
+ assert.deepEqual(findClientRouteFiles(path.join(root, 'client/pages')), [routeFilepath]);
193
+ });
194
+
195
+ test('route indexer rejects legacy contextual route imports and top-level router calls', () => {
196
+ const root = createTempDir();
197
+ const filepath = path.join(root, 'server/routes/legacy.ts');
198
+
199
+ writeFile(
200
+ filepath,
201
+ `import { Router } from '@app';
202
+
203
+ Router.get('/legacy', {}, async () => ({ ok: true }));
204
+ `,
205
+ );
206
+
207
+ assert.throws(
208
+ () => indexRouteDefinitions({ side: 'server', sourceFilepath: filepath }),
209
+ /imports @app/,
210
+ );
211
+ });
212
+
213
+ test('route indexer rejects runtime metadata expressions', () => {
214
+ const root = createTempDir();
215
+ const filepath = path.join(root, 'client/pages/dynamic.tsx');
216
+
217
+ writeFile(
218
+ filepath,
219
+ `import { definePageRoute } from '@common/router/definitions';
220
+ import runtimeConfig from '../runtimeConfig';
221
+
222
+ export default definePageRoute({
223
+ path: runtimeConfig.path,
224
+ options: {},
225
+ data: null,
226
+ render: () => null,
227
+ });
228
+ `,
229
+ );
230
+
231
+ assert.throws(
232
+ () => indexRouteDefinitions({ side: 'client', sourceFilepath: filepath }),
233
+ /definePageRoute path must be a serializable static literal or const-only expression/,
234
+ );
235
+ });
236
+
237
+ test('server route factories keep app references inside runtime registration', () => {
238
+ const root = createTempDir();
239
+ const filepath = path.join(root, 'server/routes/api.ts');
240
+
241
+ writeFile(
242
+ filepath,
243
+ `import { defineServerRoute, defineServerRoutes } from '@common/router/definitions';
244
+
245
+ const base = '/api';
246
+
247
+ export default defineServerRoutes(({ Users }) => [
248
+ defineServerRoute({
249
+ method: 'GET',
250
+ path: base + '/users',
251
+ options: {},
252
+ handler: async () => Users.list(),
253
+ }),
254
+ ]);
255
+ `,
256
+ );
257
+
258
+ const [definition] = indexRouteDefinitions({ side: 'server', sourceFilepath: filepath });
259
+
260
+ assert.equal(definition.methodName, 'get');
261
+ assert.equal(definition.path, '/api/users');
262
+ assert.equal(definition.targetResolution, 'static-expression');
263
+ });
264
+
265
+ test('route definitions register through explicit router registrar', () => {
266
+ const calls = [];
267
+
268
+ registerRouteDefinition(
269
+ {
270
+ registerRouteDefinition: (definition, metadata) => {
271
+ calls.push({ definition, metadata });
272
+ },
273
+ },
274
+ { kind: 'server', method: 'GET', path: '/ready', options: {}, handler: () => true },
275
+ { id: 'route-ready' },
276
+ );
277
+
278
+ assert.equal(calls.length, 1);
279
+ assert.equal(calls[0].definition.path, '/ready');
280
+ assert.equal(calls[0].metadata.id, 'route-ready');
281
+ assert.throws(
282
+ () =>
283
+ registerRouteDefinition(
284
+ { get: () => true },
285
+ { kind: 'server', method: 'GET', path: '/ready', options: {}, handler: () => true },
286
+ ),
287
+ /registerRouteDefinition/,
288
+ );
289
+ });
290
+
291
+ test('expressHandler preserves next-based middleware behavior', async () => {
292
+ const response = new EventEmitter();
293
+ let called = false;
294
+
295
+ const handler = expressHandler((request, _response, next, context) => {
296
+ assert.equal(request.url, '/health');
297
+ assert.equal(context.marker, 'ctx');
298
+ called = true;
299
+ next();
300
+ });
301
+
302
+ await handler({
303
+ marker: 'ctx',
304
+ request: {
305
+ req: { url: '/health' },
306
+ res: response,
307
+ },
308
+ });
309
+
310
+ assert.equal(called, true);
311
+ });
312
+
313
+ test('controller definitions parse input through explicit actions', () => {
314
+ const controller = defineController({
315
+ path: 'Example',
316
+ actions: {
317
+ Save: defineAction({
318
+ input: schema.object({ name: schema.string() }),
319
+ handler: ({ input }) => ({ greeting: `Hello ${input.name}` }),
320
+ }),
321
+ },
322
+ });
323
+
324
+ const result = runControllerAction(controller.actions.Save, {
325
+ app: {},
326
+ request: { data: { name: 'Proteum' } },
327
+ });
328
+
329
+ assert.deepEqual(result, { greeting: 'Hello Proteum' });
330
+ });
331
+
332
+ test('service model accessor prefers explicit Models service without recursing through inherited app getter', () => {
333
+ class ModelConsumer extends Service {}
334
+
335
+ const app = Object.create(Service.prototype);
336
+ app.app = app;
337
+ app.Models = { client: { model: true } };
338
+
339
+ const service = new ModelConsumer(app, {}, app);
340
+
341
+ assert.deepEqual(service.models, { model: true });
342
+ });
343
+
344
+ test('controller action context reads Models service without recursing through inherited app getter', () => {
345
+ const app = Object.create(Service.prototype);
346
+ app.app = app;
347
+ app.Models = { client: { model: true } };
348
+
349
+ const controller = defineController({
350
+ path: 'Example',
351
+ actions: {
352
+ ReadModels: defineAction({
353
+ handler: ({ models }) => models,
354
+ }),
355
+ },
356
+ });
357
+
358
+ const result = runControllerAction(controller.actions.ReadModels, {
359
+ app,
360
+ request: { data: {} },
361
+ });
362
+
363
+ assert.deepEqual(result, { model: true });
364
+ });
365
+
366
+ test('controller indexer rejects legacy controller classes', () => {
367
+ const root = createTempDir();
368
+ const controllersRoot = path.join(root, 'server/controllers');
369
+ const filepath = path.join(controllersRoot, 'Legacy.ts');
370
+
371
+ writeFile(
372
+ filepath,
373
+ `import Controller from '@server/app/controller';
374
+
375
+ export default class Legacy extends Controller {
376
+ public Test() {
377
+ return true;
378
+ }
379
+ }
380
+ `,
381
+ );
382
+
383
+ assert.throws(
384
+ () => indexControllers([{ importPrefix: '@/server/controllers/', root: controllersRoot }]),
385
+ /legacy controller class/,
386
+ );
387
+ });
388
+
389
+ test('service artifact parser reads defineApplication router factories', () => {
390
+ const root = createTempDir();
391
+ const filepath = path.join(root, 'server/index.ts');
392
+ const { parseAppBootstrapSource } = loadServiceArtifactsForAppRoot(root);
393
+
394
+ writeFile(
395
+ filepath,
396
+ `import { defineApplication } from '@server/app';
397
+ import Router from '@server/services/router';
398
+ import SchemaRouter from '@server/services/schema/router';
399
+
400
+ export default defineApplication({
401
+ router: (app) => new Router(
402
+ app,
403
+ {
404
+ plugins: {
405
+ schema: new SchemaRouter({}, app),
406
+ },
407
+ },
408
+ app,
409
+ ),
410
+ });
411
+ `,
412
+ );
413
+
414
+ const bootstrap = parseAppBootstrapSource(filepath);
415
+
416
+ assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Router']);
417
+ assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
418
+ });
419
+
420
+ test('service artifact parser reads named defineApplication router factories', () => {
421
+ const root = createTempDir();
422
+ const filepath = path.join(root, 'server/index.ts');
423
+ const { parseAppBootstrapSource } = loadServiceArtifactsForAppRoot(root);
424
+
425
+ writeFile(
426
+ filepath,
427
+ `import { defineApplication } from '@server/app';
428
+ import Router from '@server/services/router';
429
+ import SchemaRouter from '@server/services/schema/router';
430
+
431
+ const createRouter = (app) => new Router(
432
+ app,
433
+ {
434
+ plugins: {
435
+ schema: new SchemaRouter({}, app),
436
+ },
437
+ },
438
+ app,
439
+ );
440
+
441
+ const App = defineApplication({
442
+ router: createRouter,
443
+ });
444
+
445
+ export default App;
446
+ `,
447
+ );
448
+
449
+ const bootstrap = parseAppBootstrapSource(filepath);
450
+
451
+ assert.deepEqual(bootstrap.rootServices.map((service) => service.registeredName), ['Router']);
452
+ assert.deepEqual(bootstrap.routerPlugins.map((service) => service.registeredName), ['schema']);
453
+ });
@@ -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: (app) => ({
381
+ Router: new Router(
382
+ app,
383
+ {
384
+ ...appConfig.routerBaseConfig,
385
+ plugins: {
386
+ schema: new SchemaRouter({}, app),
387
+ },
386
388
  },
387
- },
388
- this,
389
- );
390
- }
389
+ app,
390
+ ),
391
+ }),
392
+ });
391
393
  `,
392
394
  );
393
395
  writeFile(
@@ -411,48 +413,48 @@ export default class TranspileWatchClient extends ClientApplication {
411
413
  );
412
414
  writeFile(
413
415
  path.join(appRoot, 'client', 'pages', 'index.tsx'),
414
- `import Router from '@/client/router';
416
+ `import { definePageRoute } from '@common/router/definitions';
415
417
  import { SharedMarker } from '@test/shared';
416
418
 
417
- Router.page(
418
- '/',
419
- {
419
+ export default definePageRoute({
420
+ path: '/',
421
+ options: {
420
422
  auth: false,
421
423
  layout: false,
422
424
  },
423
- null,
424
- () => {
425
+ data: null,
426
+ render: () => {
425
427
  return (
426
428
  <main>
427
429
  <SharedMarker />
428
430
  </main>
429
431
  );
430
432
  },
431
- );
433
+ });
432
434
  `,
433
435
  );
434
436
  if (options.staticPage) {
435
437
  writeFile(
436
438
  path.join(appRoot, 'client', 'pages', 'static-cache.tsx'),
437
- `import Router from '@/client/router';
439
+ `import { definePageRoute } from '@common/router/definitions';
438
440
  import { SharedMarker } from '@test/shared';
439
441
 
440
- Router.page(
441
- '/static-cache',
442
- {
442
+ export default definePageRoute({
443
+ path: '/static-cache',
444
+ options: {
443
445
  auth: false,
444
446
  layout: false,
445
447
  static: { urls: ['/static-cache'] },
446
448
  },
447
- null,
448
- () => {
449
+ data: null,
450
+ render: () => {
449
451
  return (
450
452
  <main>
451
453
  <SharedMarker />
452
454
  </main>
453
455
  );
454
456
  },
455
- );
457
+ });
456
458
  `,
457
459
  );
458
460
  }
@@ -539,8 +541,13 @@ test('proteum dev invalidates client assets and reloads for transpiled package s
539
541
 
540
542
  try {
541
543
  await waitForSessionReady(sessionFile, child, getOutput);
544
+ await request(port, '/', { Accept: 'text/html' });
542
545
 
543
- const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
546
+ const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL').catch(
547
+ (error) => {
548
+ throw new Error(`${error.message}\n${getOutput()}`);
549
+ },
550
+ );
544
551
  const initialScriptContent = fs.readFileSync(initialScriptAsset, 'utf8');
545
552
  const scriptReloadStream = await connectToReloadStream(port + 1);
546
553
 
@@ -608,7 +615,9 @@ test('proteum dev applies router HTTP cache config to HTML and public assets', {
608
615
  const staticResponse = await waitForHeader(port, '/static-cache', 'cache-control', staticHtmlCacheControl);
609
616
  assert.equal(staticResponse.headers.get('surrogate-control'), 'static-surrogate');
610
617
 
611
- const asset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
618
+ const asset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL').catch((error) => {
619
+ throw new Error(`${error.message}\n${getOutput()}`);
620
+ });
612
621
  const { response: assetResponse } = await request(port, toPublicAssetUrl(appRoot, asset));
613
622
 
614
623
  assert.equal(assetResponse.headers.get('cache-control'), publicAssetCacheControl);
@@ -11,6 +11,17 @@ const lint = (code) => {
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 () => {
@@ -99,8 +136,9 @@ test('proteum lint allows surfacing a message derived from the caught error', ()
99
136
  test('proteum lint allows routing promise failures to 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
  `);