@open-mercato/cli 0.4.9-develop-7afbe1e834 → 0.4.9-develop-94fb251ed3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.4.9-develop-7afbe1e834",
3
+ "version": "0.4.9-develop-94fb251ed3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -58,16 +58,16 @@
58
58
  "@mikro-orm/core": "^6.6.2",
59
59
  "@mikro-orm/migrations": "^6.6.2",
60
60
  "@mikro-orm/postgresql": "^6.6.2",
61
- "@open-mercato/shared": "0.4.9-develop-7afbe1e834",
61
+ "@open-mercato/shared": "0.4.9-develop-94fb251ed3",
62
62
  "pg": "8.20.0",
63
63
  "testcontainers": "^11.12.0",
64
64
  "typescript": "^5.9.3"
65
65
  },
66
66
  "peerDependencies": {
67
- "@open-mercato/shared": "0.4.9-develop-7afbe1e834"
67
+ "@open-mercato/shared": "0.4.9-develop-94fb251ed3"
68
68
  },
69
69
  "devDependencies": {
70
- "@open-mercato/shared": "0.4.9-develop-7afbe1e834",
70
+ "@open-mercato/shared": "0.4.9-develop-94fb251ed3",
71
71
  "@types/jest": "^30.0.0",
72
72
  "jest": "^30.2.0",
73
73
  "ts-jest": "^29.4.6"
@@ -71,6 +71,50 @@ function readGenerated(tmpDir: string, filename: string): string | null {
71
71
  return fs.readFileSync(filePath, 'utf8')
72
72
  }
73
73
 
74
+ // Inline CommonJS generators.ts that provides the security generator plugins.
75
+ // Written as plain JS (no TypeScript) so it can be dynamically imported from tmpDir
76
+ // without ts-jest transpilation.
77
+ const SECURITY_GENERATORS_CONTENT = `'use strict'
78
+ function buildMfaOutput({ importSection, entriesLiteral }) {
79
+ return '// AUTO-GENERATED by mercato generate registry\\n'
80
+ + (importSection ? importSection + '\\n' : '')
81
+ + 'type SecurityMfaProviderEntry = { moduleId: string; providers: unknown[] }\\n\\n'
82
+ + 'export const securityMfaProviderEntries: SecurityMfaProviderEntry[] = [\\n'
83
+ + (entriesLiteral ? ' ' + entriesLiteral + '\\n' : '')
84
+ + ']\\n'
85
+ }
86
+ function buildSudoOutput({ importSection, entriesLiteral }) {
87
+ return '// AUTO-GENERATED by mercato generate registry\\n'
88
+ + "import type { SecuritySudoTarget, SecuritySudoTargetEntry } from '@open-mercato/enterprise/modules/security'\\n"
89
+ + (importSection ? '\\n' + importSection + '\\n' : '\\n') + '\\n'
90
+ + 'type SecuritySudoTargetEntryRaw = { moduleId: string; targets: Array<Record<string, unknown>> }\\n\\n'
91
+ + 'const entriesRaw: SecuritySudoTargetEntryRaw[] = [\\n'
92
+ + (entriesLiteral ? ' ' + entriesLiteral + '\\n' : '')
93
+ + ']\\n\\n'
94
+ + 'export const securitySudoTargetEntries: SecuritySudoTargetEntry[] = entriesRaw.map(function(e) { return { moduleId: e.moduleId, targets: e.targets } })\\n'
95
+ }
96
+ module.exports = {
97
+ generatorPlugins: [
98
+ {
99
+ id: 'security.mfa-providers',
100
+ conventionFile: 'security.mfa-providers.ts',
101
+ importPrefix: 'SECURITY_MFA_PROVIDERS',
102
+ configExpr: function(n, id) { return "{ moduleId: '" + id + "', providers: ((" + n + ".default ?? " + n + ".mfaProviders ?? []) as unknown[]) }" },
103
+ outputFileName: 'security-mfa-providers.generated.ts',
104
+ buildOutput: buildMfaOutput,
105
+ },
106
+ {
107
+ id: 'security.sudo',
108
+ conventionFile: 'security.sudo.ts',
109
+ importPrefix: 'SECURITY_SUDO',
110
+ configExpr: function(n, id) { return "{ moduleId: '" + id + "', targets: ((" + n + ".default ?? " + n + ".sudoTargets ?? []) as Array<Record<string, unknown>>) }" },
111
+ outputFileName: 'security-sudo.generated.ts',
112
+ buildOutput: buildSudoOutput,
113
+ },
114
+ ],
115
+ }
116
+ `
117
+
74
118
  beforeEach(() => {
75
119
  tmpDir = createTmpDir()
76
120
  })
@@ -361,7 +405,7 @@ describe('generateModuleRegistryCli with module subsets', () => {
361
405
  })
362
406
 
363
407
  describe('all generated files are valid with varying subsets', () => {
364
- it('produces all 9 generated files even when no modules have matching content', async () => {
408
+ it('produces all generated files even when no modules have matching content', async () => {
365
409
  scaffoldModule(tmpDir, 'bare_mod', 'pkg', ['acl.ts'])
366
410
  const enabled: ModuleEntry[] = [
367
411
  { id: 'bare_mod', from: '@open-mercato/core' },
@@ -380,6 +424,8 @@ describe('all generated files are valid with varying subsets', () => {
380
424
  'events.generated.ts',
381
425
  'analytics.generated.ts',
382
426
  'translations-fields.generated.ts',
427
+ 'frontend-middleware.generated.ts',
428
+ 'backend-middleware.generated.ts',
383
429
  ]
384
430
  for (const file of expectedFiles) {
385
431
  const content = readGenerated(tmpDir, file)
@@ -433,6 +479,50 @@ describe('all generated files are valid with varying subsets', () => {
433
479
  expect(aiTools).not.toContain('no_ai')
434
480
  })
435
481
 
482
+ it('security generated registries are empty when no module provides security convention files', async () => {
483
+ scaffoldModule(tmpDir, 'no_security', 'pkg', ['setup.ts'])
484
+ touchFile(
485
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'no_security', 'generators.ts'),
486
+ SECURITY_GENERATORS_CONTENT,
487
+ )
488
+ const resolver = createMockResolver(tmpDir, [
489
+ { id: 'no_security', from: '@open-mercato/core' },
490
+ ])
491
+ await generateModuleRegistry({ resolver, quiet: true })
492
+
493
+ const mfaProviders = readGenerated(tmpDir, 'security-mfa-providers.generated.ts')!
494
+ const sudoTargets = readGenerated(tmpDir, 'security-sudo.generated.ts')!
495
+
496
+ expect(mfaProviders).toContain('export const securityMfaProviderEntries')
497
+ expect(mfaProviders).not.toContain('no_security')
498
+ expect(sudoTargets).toContain('export const securitySudoTargetEntries')
499
+ expect(sudoTargets).toContain('const entriesRaw: SecuritySudoTargetEntryRaw[] = [\n]')
500
+ expect(sudoTargets).not.toContain('no_security')
501
+ })
502
+
503
+ it('discovers security convention files into dedicated generated registries', async () => {
504
+ scaffoldModule(tmpDir, 'security_ext', 'pkg', [
505
+ 'security.mfa-providers.ts',
506
+ 'security.sudo.ts',
507
+ ])
508
+ touchFile(
509
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'security_ext', 'generators.ts'),
510
+ SECURITY_GENERATORS_CONTENT,
511
+ )
512
+ const resolver = createMockResolver(tmpDir, [
513
+ { id: 'security_ext', from: '@open-mercato/core' },
514
+ ])
515
+ await generateModuleRegistry({ resolver, quiet: true })
516
+
517
+ const mfaProviders = readGenerated(tmpDir, 'security-mfa-providers.generated.ts')!
518
+ const sudoTargets = readGenerated(tmpDir, 'security-sudo.generated.ts')!
519
+
520
+ expect(mfaProviders).toContain('security_ext')
521
+ expect(mfaProviders).toContain('mfaProviders')
522
+ expect(sudoTargets).toContain('security_ext')
523
+ expect(sudoTargets).toContain('sudoTargets')
524
+ })
525
+
436
526
  it('notifications.generated.ts uses typed fallback for legacy "types" export', async () => {
437
527
  scaffoldModule(tmpDir, 'notif_mod', 'pkg', ['notifications.ts'])
438
528
  const resolver = createMockResolver(tmpDir, [
@@ -444,4 +534,20 @@ describe('all generated files are valid with varying subsets', () => {
444
534
  expect(notifications).toContain('as any).types')
445
535
  expect(notifications).toContain('as NotificationTypeDefinition[]')
446
536
  })
537
+
538
+ it('discovers frontend and backend middleware conventions', async () => {
539
+ scaffoldModule(tmpDir, 'security', 'pkg', [
540
+ 'frontend/middleware.ts',
541
+ 'backend/middleware.ts',
542
+ ])
543
+ const resolver = createMockResolver(tmpDir, [
544
+ { id: 'security', from: '@open-mercato/core' },
545
+ ])
546
+ await generateModuleRegistry({ resolver, quiet: true })
547
+
548
+ const frontendMiddleware = readGenerated(tmpDir, 'frontend-middleware.generated.ts')!
549
+ const backendMiddleware = readGenerated(tmpDir, 'backend-middleware.generated.ts')!
550
+ expect(frontendMiddleware).toContain("moduleId: 'security'")
551
+ expect(backendMiddleware).toContain("moduleId: 'security'")
552
+ })
447
553
  })
@@ -339,7 +339,8 @@ describe('resolveModuleFile', () => {
339
339
  const conventionFiles = [
340
340
  'acl.ts', 'ce.ts', 'search.ts', 'notifications.ts',
341
341
  'ai-tools.ts', 'events.ts', 'analytics.ts', 'setup.ts',
342
- 'translations.ts', 'data/extensions.ts', 'data/fields.ts',
342
+ 'translations.ts', 'security.mfa-providers.ts', 'security.sudo.ts',
343
+ 'data/extensions.ts', 'data/fields.ts',
343
344
  ]
344
345
  for (const file of conventionFiles) {
345
346
  touch(file, 'pkg')
@@ -405,6 +405,8 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
405
405
  const eventsChecksumFile = path.join(outputDir, 'events.generated.checksum')
406
406
  const analyticsOutFile = path.join(outputDir, 'analytics.generated.ts')
407
407
  const analyticsChecksumFile = path.join(outputDir, 'analytics.generated.checksum')
408
+ const bootstrapRegsOutFile = path.join(outputDir, 'bootstrap-registrations.generated.ts')
409
+ const bootstrapRegsChecksumFile = path.join(outputDir, 'bootstrap-registrations.generated.checksum')
408
410
  const transFieldsOutFile = path.join(outputDir, 'translations-fields.generated.ts')
409
411
  const transFieldsChecksumFile = path.join(outputDir, 'translations-fields.generated.checksum')
410
412
  const enrichersOutFile = path.join(outputDir, 'enrichers.generated.ts')
@@ -419,8 +421,37 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
419
421
  const guardsChecksumFile = path.join(outputDir, 'guards.generated.checksum')
420
422
  const commandInterceptorsOutFile = path.join(outputDir, 'command-interceptors.generated.ts')
421
423
  const commandInterceptorsChecksumFile = path.join(outputDir, 'command-interceptors.generated.checksum')
424
+ const frontendMiddlewareOutFile = path.join(outputDir, 'frontend-middleware.generated.ts')
425
+ const frontendMiddlewareChecksumFile = path.join(outputDir, 'frontend-middleware.generated.checksum')
426
+ const backendMiddlewareOutFile = path.join(outputDir, 'backend-middleware.generated.ts')
427
+ const backendMiddlewareChecksumFile = path.join(outputDir, 'backend-middleware.generated.checksum')
422
428
 
423
429
  const enabled = resolver.loadEnabledModules()
430
+
431
+ // Pre-pass: collect generator plugins from each enabled module's generators.ts
432
+ const pluginRegistry = new Map<string, import('@open-mercato/shared/modules/generators').GeneratorPlugin>()
433
+ const pluginState = new Map<string, { imports: string[]; configs: string[] }>()
434
+ for (const entry of enabled) {
435
+ const roots = resolver.getModulePaths(entry)
436
+ const rawImps = resolver.getModuleImportBase(entry)
437
+ const isAppMod = entry.from === '@app'
438
+ const appImportBase = isAppMod ? `../../src/modules/${entry.id}` : rawImps.appBase
439
+ const imps: ModuleImports = { appBase: appImportBase, pkgBase: rawImps.pkgBase }
440
+ const resolved = resolveModuleFile(roots, imps, 'generators.ts')
441
+ if (!resolved) continue
442
+ try {
443
+ const pluginMod = await import(resolved.absolutePath)
444
+ const plugins: import('@open-mercato/shared/modules/generators').GeneratorPlugin[] =
445
+ pluginMod.generatorPlugins ?? pluginMod.default ?? []
446
+ for (const plugin of plugins) {
447
+ if (!pluginRegistry.has(plugin.id)) {
448
+ pluginRegistry.set(plugin.id, plugin)
449
+ pluginState.set(plugin.id, { imports: [], configs: [] })
450
+ }
451
+ }
452
+ } catch {}
453
+ }
454
+
424
455
  const imports: string[] = []
425
456
  const moduleDecls: string[] = []
426
457
  // Mutable ref so extracted helper functions can increment the shared counter
@@ -462,6 +493,10 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
462
493
  const guardImports: string[] = []
463
494
  const commandInterceptorConfigs: string[] = []
464
495
  const commandInterceptorImports: string[] = []
496
+ const frontendMiddlewareConfigs: string[] = []
497
+ const frontendMiddlewareImports: string[] = []
498
+ const backendMiddlewareConfigs: string[] = []
499
+ const backendMiddlewareImports: string[] = []
465
500
 
466
501
  // UMES conflict detection: collect file paths during module processing
467
502
  const umesConflictSources: Array<{
@@ -735,6 +770,18 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
735
770
  configExpr: (n, id) => `{ moduleId: '${id}', fields: (${n}.default ?? ${n}.translatableFields ?? {}) as Record<string, string[]> }`,
736
771
  })
737
772
 
773
+ // Generator plugins: process each registered plugin's convention file
774
+ for (const plugin of pluginRegistry.values()) {
775
+ processStandaloneConfig({
776
+ roots, imps, modId, importIdRef,
777
+ relativePath: plugin.conventionFile,
778
+ prefix: plugin.importPrefix,
779
+ standaloneImports: pluginState.get(plugin.id)!.imports,
780
+ standaloneConfigs: pluginState.get(plugin.id)!.configs,
781
+ configExpr: plugin.configExpr,
782
+ })
783
+ }
784
+
738
785
  // Inbox Actions: inbox-actions.ts
739
786
  {
740
787
  const resolved = resolveModuleFile(roots, imps, 'inbox-actions.ts')
@@ -768,6 +815,26 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
768
815
  configExpr: (n, id) => `{ moduleId: '${id}', interceptors: ((${n} as any).interceptors ?? (${n} as any).default ?? []) }`,
769
816
  })
770
817
 
818
+ // 10g. Frontend page middleware: frontend/middleware.ts
819
+ processStandaloneConfig({
820
+ roots, imps, modId, importIdRef,
821
+ relativePath: 'frontend/middleware.ts',
822
+ prefix: 'FRONTEND_MIDDLEWARE',
823
+ standaloneImports: frontendMiddlewareImports,
824
+ standaloneConfigs: frontendMiddlewareConfigs,
825
+ configExpr: (n, id) => `{ moduleId: '${id}', middleware: ((${n} as any).middleware ?? (${n} as any).default ?? []) }`,
826
+ })
827
+
828
+ // 10h. Backend page middleware: backend/middleware.ts
829
+ processStandaloneConfig({
830
+ roots, imps, modId, importIdRef,
831
+ relativePath: 'backend/middleware.ts',
832
+ prefix: 'BACKEND_MIDDLEWARE',
833
+ standaloneImports: backendMiddlewareImports,
834
+ standaloneConfigs: backendMiddlewareConfigs,
835
+ configExpr: (n, id) => `{ moduleId: '${id}', middleware: ((${n} as any).middleware ?? (${n} as any).default ?? []) }`,
836
+ })
837
+
771
838
  // 11. Setup: setup.ts
772
839
  {
773
840
  const setup = resolveConventionFile(roots, imps, 'setup.ts', 'SETUP', modId, importIdRef, imports)
@@ -1430,6 +1497,43 @@ export const allAiTools = aiToolConfigEntries.flatMap(e => e.tools)
1430
1497
  writeGeneratedFile({ outFile: analyticsOutFile, checksumFile: analyticsChecksumFile, content: analyticsOutput, structureChecksum, result, quiet })
1431
1498
  writeGeneratedFile({ outFile: transFieldsOutFile, checksumFile: transFieldsChecksumFile, content: transFieldsOutput, structureChecksum, result, quiet })
1432
1499
 
1500
+ // Generator plugin outputs (registered via modules' generators.ts)
1501
+ for (const [pluginId, plugin] of pluginRegistry) {
1502
+ const state = pluginState.get(pluginId)!
1503
+ const importSection = state.imports.join('\n')
1504
+ const entriesLiteral = state.configs.join(',\n ')
1505
+ const content = plugin.buildOutput({ importSection, entriesLiteral })
1506
+ const outFile = path.join(outputDir, plugin.outputFileName)
1507
+ const checksumFile = outFile.replace('.ts', '.checksum')
1508
+ writeGeneratedFile({ outFile, checksumFile, content, structureChecksum, result, quiet })
1509
+ }
1510
+
1511
+ // Bootstrap registrations: aggregate all plugin bootstrap-registration hooks into one file.
1512
+ // Always written (even when empty) so bootstrap.ts can unconditionally import it.
1513
+ {
1514
+ const bootstrapPlugins = [...pluginRegistry.values()].filter((p) => p.bootstrapRegistration)
1515
+ const allEntryImports: string[] = []
1516
+ const allRegImports: string[] = []
1517
+ const allCalls: string[] = []
1518
+ for (const plugin of bootstrapPlugins) {
1519
+ const reg = plugin.bootstrapRegistration!
1520
+ const outputBase = plugin.outputFileName.replace('.ts', '')
1521
+ allEntryImports.push(`import { ${reg.entriesExportName} } from './${outputBase}'`)
1522
+ allRegImports.push(...reg.registrationImports)
1523
+ allCalls.push(reg.buildCall(reg.entriesExportName))
1524
+ }
1525
+ const uniqueImports = [...new Set([...allEntryImports, ...allRegImports])]
1526
+ const importSection = uniqueImports.join('\n')
1527
+ const body = allCalls.length ? ` ${allCalls.join('\n ')}` : ''
1528
+ const bootstrapRegsOutput = `// AUTO-GENERATED by mercato generate registry
1529
+ ${importSection ? `${importSection}\n` : ''}
1530
+ export function runBootstrapRegistrations(): void {
1531
+ ${body}
1532
+ }
1533
+ `
1534
+ writeGeneratedFile({ outFile: bootstrapRegsOutFile, checksumFile: bootstrapRegsChecksumFile, content: bootstrapRegsOutput, structureChecksum, result, quiet })
1535
+ }
1536
+
1433
1537
  // Enrichers generated file
1434
1538
  const enricherEntriesLiteral = enricherConfigs.join(',\n ')
1435
1539
  const enricherImportSection = enricherImports.join('\n')
@@ -1518,6 +1622,46 @@ ${commandInterceptorEntriesLiteral ? ` ${commandInterceptorEntriesLiteral}\n` :
1518
1622
  `
1519
1623
  writeGeneratedFile({ outFile: commandInterceptorsOutFile, checksumFile: commandInterceptorsChecksumFile, content: commandInterceptorsOutput, structureChecksum, result, quiet })
1520
1624
 
1625
+ const frontendMiddlewareEntriesLiteral = frontendMiddlewareConfigs.join(',\n ')
1626
+ const frontendMiddlewareImportSection = frontendMiddlewareImports.join('\n')
1627
+ const frontendMiddlewareOutput = `// AUTO-GENERATED by mercato generate registry
1628
+ import type { PageMiddlewareRegistryEntry, PageRouteMiddleware } from '@open-mercato/shared/modules/middleware/page'
1629
+ ${frontendMiddlewareImportSection ? `\n${frontendMiddlewareImportSection}\n` : '\n'}type FrontendMiddlewareEntry = { moduleId: string; middleware: PageRouteMiddleware[] }
1630
+
1631
+ const entriesRaw: FrontendMiddlewareEntry[] = [
1632
+ ${frontendMiddlewareEntriesLiteral ? ` ${frontendMiddlewareEntriesLiteral}\n` : ''}]
1633
+
1634
+ export const frontendMiddlewareEntries: PageMiddlewareRegistryEntry[] = entriesRaw
1635
+ `
1636
+ writeGeneratedFile({
1637
+ outFile: frontendMiddlewareOutFile,
1638
+ checksumFile: frontendMiddlewareChecksumFile,
1639
+ content: frontendMiddlewareOutput,
1640
+ structureChecksum,
1641
+ result,
1642
+ quiet,
1643
+ })
1644
+
1645
+ const backendMiddlewareEntriesLiteral = backendMiddlewareConfigs.join(',\n ')
1646
+ const backendMiddlewareImportSection = backendMiddlewareImports.join('\n')
1647
+ const backendMiddlewareOutput = `// AUTO-GENERATED by mercato generate registry
1648
+ import type { PageMiddlewareRegistryEntry, PageRouteMiddleware } from '@open-mercato/shared/modules/middleware/page'
1649
+ ${backendMiddlewareImportSection ? `\n${backendMiddlewareImportSection}\n` : '\n'}type BackendMiddlewareEntry = { moduleId: string; middleware: PageRouteMiddleware[] }
1650
+
1651
+ const entriesRaw: BackendMiddlewareEntry[] = [
1652
+ ${backendMiddlewareEntriesLiteral ? ` ${backendMiddlewareEntriesLiteral}\n` : ''}]
1653
+
1654
+ export const backendMiddlewareEntries: PageMiddlewareRegistryEntry[] = entriesRaw
1655
+ `
1656
+ writeGeneratedFile({
1657
+ outFile: backendMiddlewareOutFile,
1658
+ checksumFile: backendMiddlewareChecksumFile,
1659
+ content: backendMiddlewareOutput,
1660
+ structureChecksum,
1661
+ result,
1662
+ quiet,
1663
+ })
1664
+
1521
1665
  return result
1522
1666
  }
1523
1667
 
@@ -14,10 +14,12 @@ import {
14
14
  readEphemeralEnvironmentState,
15
15
  clearEphemeralEnvironmentState,
16
16
  resolveBuildCacheTtlSeconds,
17
+ resolveAppReadyTimeoutMs,
17
18
  shouldReuseBuildArtifacts,
18
19
  } from '../integration'
19
20
 
20
21
  const CACHE_TTL_ENV_VAR = 'OM_INTEGRATION_BUILD_CACHE_TTL_SECONDS'
22
+ const APP_READY_TIMEOUT_ENV_VAR = 'OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS'
21
23
  const resolver = createResolver()
22
24
  const projectRootDirectory = resolver.getRootDir()
23
25
 
@@ -36,6 +38,7 @@ describe('integration cache and options', () => {
36
38
  const ephemeralEnvFilePath = path.join(projectRootDirectory, '.ai', 'qa', 'ephemeral-env.json')
37
39
  const ephemeralLegacyEnvFilePath = path.join(projectRootDirectory, '.ai', 'qa', 'ephemeral-env.md')
38
40
  const originalCacheTtl = process.env[CACHE_TTL_ENV_VAR]
41
+ const originalAppReadyTimeout = process.env[APP_READY_TIMEOUT_ENV_VAR]
39
42
  let originalEphemeralEnvState: string | null = null
40
43
  let originalEphemeralLegacyEnvState: string | null = null
41
44
 
@@ -61,6 +64,11 @@ describe('integration cache and options', () => {
61
64
  } else {
62
65
  process.env[CACHE_TTL_ENV_VAR] = originalCacheTtl
63
66
  }
67
+ if (originalAppReadyTimeout === undefined) {
68
+ delete process.env[APP_READY_TIMEOUT_ENV_VAR]
69
+ } else {
70
+ process.env[APP_READY_TIMEOUT_ENV_VAR] = originalAppReadyTimeout
71
+ }
64
72
  await restoreEphemeralStateFiles(originalEphemeralEnvState, originalEphemeralLegacyEnvState)
65
73
  })
66
74
 
@@ -68,6 +76,9 @@ describe('integration cache and options', () => {
68
76
  const baseUrl = 'http://127.0.0.1:5001'
69
77
  const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
70
78
  const url = typeof input === 'string' ? input : String(input)
79
+ if (url.endsWith('/api/auth/login')) {
80
+ return { status: 401, text: async () => '' } as unknown as Response
81
+ }
71
82
  if (url.endsWith('/login')) {
72
83
  return {
73
84
  status: 200,
@@ -77,9 +88,6 @@ describe('integration cache and options', () => {
77
88
  if (url.includes('/_next/static/chunks/app-healthcheck.js')) {
78
89
  return { status: 200, text: async () => '' } as unknown as Response
79
90
  }
80
- if (url.endsWith('/api/auth/login')) {
81
- return { status: 401, text: async () => '' } as unknown as Response
82
- }
83
91
  return { status: 200, text: async () => '' } as unknown as Response
84
92
  })
85
93
 
@@ -113,6 +121,87 @@ describe('integration cache and options', () => {
113
121
  }
114
122
  }, 20000)
115
123
 
124
+ it('reuses an existing environment when /login returns a redirect status other than 302', async () => {
125
+ const baseUrl = 'http://127.0.0.1:5001'
126
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
127
+ const url = typeof input === 'string' ? input : String(input)
128
+ if (url.endsWith('/api/auth/login')) {
129
+ return { status: 401, text: async () => '' } as unknown as Response
130
+ }
131
+ if (url.endsWith('/login')) {
132
+ return { status: 308, text: async () => '' } as unknown as Response
133
+ }
134
+ return { status: 200, text: async () => '' } as unknown as Response
135
+ })
136
+
137
+ try {
138
+ await writeEphemeralEnvironmentState({
139
+ baseUrl,
140
+ port: 5001,
141
+ logPrefix: 'integration',
142
+ captureScreenshots: true,
143
+ })
144
+
145
+ const environment = await tryReuseExistingEnvironment({
146
+ verbose: false,
147
+ captureScreenshots: true,
148
+ logPrefix: 'integration',
149
+ forceRebuild: false,
150
+ })
151
+
152
+ expect(environment).not.toBeNull()
153
+ expect(environment).toMatchObject({
154
+ baseUrl,
155
+ port: 5001,
156
+ ownedByCurrentProcess: false,
157
+ })
158
+ } finally {
159
+ fetchSpy.mockRestore()
160
+ }
161
+ }, 20000)
162
+
163
+ it('reuses an existing environment when /login returns healthy HTML without static asset references', async () => {
164
+ const baseUrl = 'http://127.0.0.1:5001'
165
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
166
+ const url = typeof input === 'string' ? input : String(input)
167
+ if (url.endsWith('/api/auth/login')) {
168
+ return { status: 401, text: async () => '' } as unknown as Response
169
+ }
170
+ if (url.endsWith('/login')) {
171
+ return {
172
+ status: 200,
173
+ text: async () => '<!doctype html><html><body><form data-auth-ready="0"></form></body></html>',
174
+ } as unknown as Response
175
+ }
176
+ return { status: 200, text: async () => '' } as unknown as Response
177
+ })
178
+
179
+ try {
180
+ await writeEphemeralEnvironmentState({
181
+ baseUrl,
182
+ port: 5001,
183
+ logPrefix: 'integration',
184
+ captureScreenshots: false,
185
+ })
186
+
187
+ const environment = await tryReuseExistingEnvironment({
188
+ verbose: false,
189
+ captureScreenshots: false,
190
+ logPrefix: 'integration',
191
+ forceRebuild: false,
192
+ })
193
+
194
+ expect(environment).not.toBeNull()
195
+ expect(environment).toMatchObject({
196
+ baseUrl,
197
+ port: 5001,
198
+ ownedByCurrentProcess: false,
199
+ })
200
+ } finally {
201
+ fetchSpy.mockRestore()
202
+ }
203
+ })
204
+
116
205
  it('falls back to rebuilding when the ephemeral environment state is unreachable', async () => {
117
206
  const baseUrl = 'http://127.0.0.1:5001'
118
207
  const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ status: 500 } as unknown as Response)
@@ -223,6 +312,20 @@ describe('integration cache and options', () => {
223
312
  warn.mockRestore()
224
313
  })
225
314
 
315
+ it('resolves app readiness timeout from env variable', () => {
316
+ delete process.env[APP_READY_TIMEOUT_ENV_VAR]
317
+ expect(resolveAppReadyTimeoutMs('integration')).toBe(90_000)
318
+
319
+ process.env[APP_READY_TIMEOUT_ENV_VAR] = '180'
320
+ expect(resolveAppReadyTimeoutMs('integration')).toBe(180_000)
321
+
322
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
323
+ process.env[APP_READY_TIMEOUT_ENV_VAR] = '0'
324
+ expect(resolveAppReadyTimeoutMs('integration')).toBe(90_000)
325
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Invalid'))
326
+ warn.mockRestore()
327
+ })
328
+
226
329
  it('reuses build artifacts only with matching source fingerprint and fresh cache state', async () => {
227
330
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'om-int-cache-test-'))
228
331
  try {