@open-mercato/cli 0.4.7 → 0.4.8-canary-d3f23076fd

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.7",
3
+ "version": "0.4.8-canary-d3f23076fd",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -58,21 +58,22 @@
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.7",
61
+ "@open-mercato/shared": "0.4.8-canary-d3f23076fd",
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.7"
67
+ "@open-mercato/shared": "0.4.8-canary-d3f23076fd"
68
68
  },
69
69
  "devDependencies": {
70
- "@open-mercato/shared": "0.4.7",
70
+ "@open-mercato/shared": "0.4.8-canary-d3f23076fd",
71
71
  "@types/jest": "^30.0.0",
72
72
  "jest": "^30.2.0",
73
73
  "ts-jest": "^29.4.6"
74
74
  },
75
75
  "publishConfig": {
76
76
  "access": "public"
77
- }
77
+ },
78
+ "stableVersion": "0.4.7"
78
79
  }
@@ -361,7 +361,7 @@ describe('generateModuleRegistryCli with module subsets', () => {
361
361
  })
362
362
 
363
363
  describe('all generated files are valid with varying subsets', () => {
364
- it('produces all 9 generated files even when no modules have matching content', async () => {
364
+ it('produces all generated files even when no modules have matching content', async () => {
365
365
  scaffoldModule(tmpDir, 'bare_mod', 'pkg', ['acl.ts'])
366
366
  const enabled: ModuleEntry[] = [
367
367
  { id: 'bare_mod', from: '@open-mercato/core' },
@@ -380,6 +380,10 @@ describe('all generated files are valid with varying subsets', () => {
380
380
  'events.generated.ts',
381
381
  'analytics.generated.ts',
382
382
  'translations-fields.generated.ts',
383
+ 'security-mfa-providers.generated.ts',
384
+ 'security-sudo.generated.ts',
385
+ 'frontend-middleware.generated.ts',
386
+ 'backend-middleware.generated.ts',
383
387
  ]
384
388
  for (const file of expectedFiles) {
385
389
  const content = readGenerated(tmpDir, file)
@@ -433,6 +437,42 @@ describe('all generated files are valid with varying subsets', () => {
433
437
  expect(aiTools).not.toContain('no_ai')
434
438
  })
435
439
 
440
+ it('security generated registries are empty when no module provides security convention files', async () => {
441
+ scaffoldModule(tmpDir, 'no_security', 'pkg', ['setup.ts'])
442
+ const resolver = createMockResolver(tmpDir, [
443
+ { id: 'no_security', from: '@open-mercato/core' },
444
+ ])
445
+ await generateModuleRegistry({ resolver, quiet: true })
446
+
447
+ const mfaProviders = readGenerated(tmpDir, 'security-mfa-providers.generated.ts')!
448
+ const sudoTargets = readGenerated(tmpDir, 'security-sudo.generated.ts')!
449
+
450
+ expect(mfaProviders).toContain('export const securityMfaProviderEntries')
451
+ expect(mfaProviders).not.toContain('no_security')
452
+ expect(sudoTargets).toContain('export const securitySudoTargetEntries')
453
+ expect(sudoTargets).toContain('const entriesRaw: SecuritySudoTargetEntryRaw[] = [\n]')
454
+ expect(sudoTargets).not.toContain('no_security')
455
+ })
456
+
457
+ it('discovers security convention files into dedicated generated registries', async () => {
458
+ scaffoldModule(tmpDir, 'security_ext', 'pkg', [
459
+ 'security.mfa-providers.ts',
460
+ 'security.sudo.ts',
461
+ ])
462
+ const resolver = createMockResolver(tmpDir, [
463
+ { id: 'security_ext', from: '@open-mercato/core' },
464
+ ])
465
+ await generateModuleRegistry({ resolver, quiet: true })
466
+
467
+ const mfaProviders = readGenerated(tmpDir, 'security-mfa-providers.generated.ts')!
468
+ const sudoTargets = readGenerated(tmpDir, 'security-sudo.generated.ts')!
469
+
470
+ expect(mfaProviders).toContain('security_ext')
471
+ expect(mfaProviders).toContain('mfaProviders')
472
+ expect(sudoTargets).toContain('security_ext')
473
+ expect(sudoTargets).toContain('sudoTargets')
474
+ })
475
+
436
476
  it('notifications.generated.ts uses typed fallback for legacy "types" export', async () => {
437
477
  scaffoldModule(tmpDir, 'notif_mod', 'pkg', ['notifications.ts'])
438
478
  const resolver = createMockResolver(tmpDir, [
@@ -444,4 +484,20 @@ describe('all generated files are valid with varying subsets', () => {
444
484
  expect(notifications).toContain('as any).types')
445
485
  expect(notifications).toContain('as NotificationTypeDefinition[]')
446
486
  })
487
+
488
+ it('discovers frontend and backend middleware conventions', async () => {
489
+ scaffoldModule(tmpDir, 'security', 'pkg', [
490
+ 'frontend/middleware.ts',
491
+ 'backend/middleware.ts',
492
+ ])
493
+ const resolver = createMockResolver(tmpDir, [
494
+ { id: 'security', from: '@open-mercato/core' },
495
+ ])
496
+ await generateModuleRegistry({ resolver, quiet: true })
497
+
498
+ const frontendMiddleware = readGenerated(tmpDir, 'frontend-middleware.generated.ts')!
499
+ const backendMiddleware = readGenerated(tmpDir, 'backend-middleware.generated.ts')!
500
+ expect(frontendMiddleware).toContain("moduleId: 'security'")
501
+ expect(backendMiddleware).toContain("moduleId: 'security'")
502
+ })
447
503
  })
@@ -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')
@@ -407,6 +407,10 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
407
407
  const analyticsChecksumFile = path.join(outputDir, 'analytics.generated.checksum')
408
408
  const transFieldsOutFile = path.join(outputDir, 'translations-fields.generated.ts')
409
409
  const transFieldsChecksumFile = path.join(outputDir, 'translations-fields.generated.checksum')
410
+ const securityMfaProvidersOutFile = path.join(outputDir, 'security-mfa-providers.generated.ts')
411
+ const securityMfaProvidersChecksumFile = path.join(outputDir, 'security-mfa-providers.generated.checksum')
412
+ const securitySudoOutFile = path.join(outputDir, 'security-sudo.generated.ts')
413
+ const securitySudoChecksumFile = path.join(outputDir, 'security-sudo.generated.checksum')
410
414
  const enrichersOutFile = path.join(outputDir, 'enrichers.generated.ts')
411
415
  const enrichersChecksumFile = path.join(outputDir, 'enrichers.generated.checksum')
412
416
  const interceptorsOutFile = path.join(outputDir, 'interceptors.generated.ts')
@@ -419,6 +423,10 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
419
423
  const guardsChecksumFile = path.join(outputDir, 'guards.generated.checksum')
420
424
  const commandInterceptorsOutFile = path.join(outputDir, 'command-interceptors.generated.ts')
421
425
  const commandInterceptorsChecksumFile = path.join(outputDir, 'command-interceptors.generated.checksum')
426
+ const frontendMiddlewareOutFile = path.join(outputDir, 'frontend-middleware.generated.ts')
427
+ const frontendMiddlewareChecksumFile = path.join(outputDir, 'frontend-middleware.generated.checksum')
428
+ const backendMiddlewareOutFile = path.join(outputDir, 'backend-middleware.generated.ts')
429
+ const backendMiddlewareChecksumFile = path.join(outputDir, 'backend-middleware.generated.checksum')
422
430
 
423
431
  const enabled = resolver.loadEnabledModules()
424
432
  const imports: string[] = []
@@ -450,6 +458,10 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
450
458
  const analyticsImports: string[] = []
451
459
  const transFieldsConfigs: string[] = []
452
460
  const transFieldsImports: string[] = []
461
+ const securityMfaProviderConfigs: string[] = []
462
+ const securityMfaProviderImports: string[] = []
463
+ const securitySudoConfigs: string[] = []
464
+ const securitySudoImports: string[] = []
453
465
  const enricherConfigs: string[] = []
454
466
  const enricherImports: string[] = []
455
467
  const interceptorConfigs: string[] = []
@@ -462,6 +474,10 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
462
474
  const guardImports: string[] = []
463
475
  const commandInterceptorConfigs: string[] = []
464
476
  const commandInterceptorImports: string[] = []
477
+ const frontendMiddlewareConfigs: string[] = []
478
+ const frontendMiddlewareImports: string[] = []
479
+ const backendMiddlewareConfigs: string[] = []
480
+ const backendMiddlewareImports: string[] = []
465
481
 
466
482
  // UMES conflict detection: collect file paths during module processing
467
483
  const umesConflictSources: Array<{
@@ -735,6 +751,24 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
735
751
  configExpr: (n, id) => `{ moduleId: '${id}', fields: (${n}.default ?? ${n}.translatableFields ?? {}) as Record<string, string[]> }`,
736
752
  })
737
753
 
754
+ processStandaloneConfig({
755
+ roots, imps, modId, importIdRef,
756
+ relativePath: 'security.mfa-providers.ts',
757
+ prefix: 'SECURITY_MFA_PROVIDERS',
758
+ standaloneImports: securityMfaProviderImports,
759
+ standaloneConfigs: securityMfaProviderConfigs,
760
+ configExpr: (n, id) => `{ moduleId: '${id}', providers: ((${n}.default ?? ${n}.mfaProviders ?? []) as unknown[]) }`,
761
+ })
762
+
763
+ processStandaloneConfig({
764
+ roots, imps, modId, importIdRef,
765
+ relativePath: 'security.sudo.ts',
766
+ prefix: 'SECURITY_SUDO',
767
+ standaloneImports: securitySudoImports,
768
+ standaloneConfigs: securitySudoConfigs,
769
+ configExpr: (n, id) => `{ moduleId: '${id}', targets: ((${n}.default ?? ${n}.sudoTargets ?? []) as Array<Record<string, unknown>>) }`,
770
+ })
771
+
738
772
  // Inbox Actions: inbox-actions.ts
739
773
  {
740
774
  const resolved = resolveModuleFile(roots, imps, 'inbox-actions.ts')
@@ -768,6 +802,26 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
768
802
  configExpr: (n, id) => `{ moduleId: '${id}', interceptors: ((${n} as any).interceptors ?? (${n} as any).default ?? []) }`,
769
803
  })
770
804
 
805
+ // 10g. Frontend page middleware: frontend/middleware.ts
806
+ processStandaloneConfig({
807
+ roots, imps, modId, importIdRef,
808
+ relativePath: 'frontend/middleware.ts',
809
+ prefix: 'FRONTEND_MIDDLEWARE',
810
+ standaloneImports: frontendMiddlewareImports,
811
+ standaloneConfigs: frontendMiddlewareConfigs,
812
+ configExpr: (n, id) => `{ moduleId: '${id}', middleware: ((${n} as any).middleware ?? (${n} as any).default ?? []) }`,
813
+ })
814
+
815
+ // 10h. Backend page middleware: backend/middleware.ts
816
+ processStandaloneConfig({
817
+ roots, imps, modId, importIdRef,
818
+ relativePath: 'backend/middleware.ts',
819
+ prefix: 'BACKEND_MIDDLEWARE',
820
+ standaloneImports: backendMiddlewareImports,
821
+ standaloneConfigs: backendMiddlewareConfigs,
822
+ configExpr: (n, id) => `{ moduleId: '${id}', middleware: ((${n} as any).middleware ?? (${n} as any).default ?? []) }`,
823
+ })
824
+
771
825
  // 11. Setup: setup.ts
772
826
  {
773
827
  const setup = resolveConventionFile(roots, imps, 'setup.ts', 'SETUP', modId, importIdRef, imports)
@@ -1151,6 +1205,31 @@ export const allTranslatableEntityTypes = Object.keys(allFields)
1151
1205
 
1152
1206
  // Auto-register on import (side-effect)
1153
1207
  registerTranslatableFields(allFields)
1208
+ `
1209
+
1210
+ const securityMfaProviderEntriesLiteral = securityMfaProviderConfigs.join(',\n ')
1211
+ const securityMfaProviderImportSection = securityMfaProviderImports.join('\n')
1212
+ const securityMfaProvidersOutput = `// AUTO-GENERATED by mercato generate registry
1213
+ ${securityMfaProviderImportSection ? `${securityMfaProviderImportSection}\n` : ''}type SecurityMfaProviderEntry = { moduleId: string; providers: unknown[] }
1214
+
1215
+ export const securityMfaProviderEntries: SecurityMfaProviderEntry[] = [
1216
+ ${securityMfaProviderEntriesLiteral ? ` ${securityMfaProviderEntriesLiteral}\n` : ''}]
1217
+ `
1218
+
1219
+ const securitySudoEntriesLiteral = securitySudoConfigs.join(',\n ')
1220
+ const securitySudoImportSection = securitySudoImports.join('\n')
1221
+ const securitySudoOutput = `// AUTO-GENERATED by mercato generate registry
1222
+ import type { SecuritySudoTarget, SecuritySudoTargetEntry } from '@open-mercato/enterprise/modules/security'
1223
+ ${securitySudoImportSection ? `\n${securitySudoImportSection}\n` : '\n'}
1224
+ type SecuritySudoTargetEntryRaw = { moduleId: string; targets: Array<Record<string, unknown>> }
1225
+
1226
+ const entriesRaw: SecuritySudoTargetEntryRaw[] = [
1227
+ ${securitySudoEntriesLiteral ? ` ${securitySudoEntriesLiteral}\n` : ''}]
1228
+
1229
+ export const securitySudoTargetEntries: SecuritySudoTargetEntry[] = entriesRaw.map((entry) => ({
1230
+ moduleId: entry.moduleId,
1231
+ targets: entry.targets as SecuritySudoTarget[],
1232
+ }))
1154
1233
  `
1155
1234
 
1156
1235
  const notificationEntriesLiteral = notificationTypes.join(',\n ')
@@ -1429,6 +1508,8 @@ export const allAiTools = aiToolConfigEntries.flatMap(e => e.tools)
1429
1508
  writeGeneratedFile({ outFile: eventsOutFile, checksumFile: eventsChecksumFile, content: eventsOutput, structureChecksum, result, quiet })
1430
1509
  writeGeneratedFile({ outFile: analyticsOutFile, checksumFile: analyticsChecksumFile, content: analyticsOutput, structureChecksum, result, quiet })
1431
1510
  writeGeneratedFile({ outFile: transFieldsOutFile, checksumFile: transFieldsChecksumFile, content: transFieldsOutput, structureChecksum, result, quiet })
1511
+ writeGeneratedFile({ outFile: securityMfaProvidersOutFile, checksumFile: securityMfaProvidersChecksumFile, content: securityMfaProvidersOutput, structureChecksum, result, quiet })
1512
+ writeGeneratedFile({ outFile: securitySudoOutFile, checksumFile: securitySudoChecksumFile, content: securitySudoOutput, structureChecksum, result, quiet })
1432
1513
 
1433
1514
  // Enrichers generated file
1434
1515
  const enricherEntriesLiteral = enricherConfigs.join(',\n ')
@@ -1518,6 +1599,46 @@ ${commandInterceptorEntriesLiteral ? ` ${commandInterceptorEntriesLiteral}\n` :
1518
1599
  `
1519
1600
  writeGeneratedFile({ outFile: commandInterceptorsOutFile, checksumFile: commandInterceptorsChecksumFile, content: commandInterceptorsOutput, structureChecksum, result, quiet })
1520
1601
 
1602
+ const frontendMiddlewareEntriesLiteral = frontendMiddlewareConfigs.join(',\n ')
1603
+ const frontendMiddlewareImportSection = frontendMiddlewareImports.join('\n')
1604
+ const frontendMiddlewareOutput = `// AUTO-GENERATED by mercato generate registry
1605
+ import type { PageMiddlewareRegistryEntry, PageRouteMiddleware } from '@open-mercato/shared/modules/middleware/page'
1606
+ ${frontendMiddlewareImportSection ? `\n${frontendMiddlewareImportSection}\n` : '\n'}type FrontendMiddlewareEntry = { moduleId: string; middleware: PageRouteMiddleware[] }
1607
+
1608
+ const entriesRaw: FrontendMiddlewareEntry[] = [
1609
+ ${frontendMiddlewareEntriesLiteral ? ` ${frontendMiddlewareEntriesLiteral}\n` : ''}]
1610
+
1611
+ export const frontendMiddlewareEntries: PageMiddlewareRegistryEntry[] = entriesRaw
1612
+ `
1613
+ writeGeneratedFile({
1614
+ outFile: frontendMiddlewareOutFile,
1615
+ checksumFile: frontendMiddlewareChecksumFile,
1616
+ content: frontendMiddlewareOutput,
1617
+ structureChecksum,
1618
+ result,
1619
+ quiet,
1620
+ })
1621
+
1622
+ const backendMiddlewareEntriesLiteral = backendMiddlewareConfigs.join(',\n ')
1623
+ const backendMiddlewareImportSection = backendMiddlewareImports.join('\n')
1624
+ const backendMiddlewareOutput = `// AUTO-GENERATED by mercato generate registry
1625
+ import type { PageMiddlewareRegistryEntry, PageRouteMiddleware } from '@open-mercato/shared/modules/middleware/page'
1626
+ ${backendMiddlewareImportSection ? `\n${backendMiddlewareImportSection}\n` : '\n'}type BackendMiddlewareEntry = { moduleId: string; middleware: PageRouteMiddleware[] }
1627
+
1628
+ const entriesRaw: BackendMiddlewareEntry[] = [
1629
+ ${backendMiddlewareEntriesLiteral ? ` ${backendMiddlewareEntriesLiteral}\n` : ''}]
1630
+
1631
+ export const backendMiddlewareEntries: PageMiddlewareRegistryEntry[] = entriesRaw
1632
+ `
1633
+ writeGeneratedFile({
1634
+ outFile: backendMiddlewareOutFile,
1635
+ checksumFile: backendMiddlewareChecksumFile,
1636
+ content: backendMiddlewareOutput,
1637
+ structureChecksum,
1638
+ result,
1639
+ quiet,
1640
+ })
1641
+
1521
1642
  return result
1522
1643
  }
1523
1644
 
@@ -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
+ })
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 {