@open-mercato/cli 0.6.5-develop.4490.1.d8e873f3cf → 0.6.5-develop.4516.1.88e6ab71a9

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.6.5-develop.4490.1.d8e873f3cf",
3
+ "version": "0.6.5-develop.4516.1.88e6ab71a9",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.3",
60
60
  "@mikro-orm/migrations": "^7.1.3",
61
61
  "@mikro-orm/postgresql": "^7.1.3",
62
- "@open-mercato/queue": "0.6.5-develop.4490.1.d8e873f3cf",
63
- "@open-mercato/shared": "0.6.5-develop.4490.1.d8e873f3cf",
62
+ "@open-mercato/queue": "0.6.5-develop.4516.1.88e6ab71a9",
63
+ "@open-mercato/shared": "0.6.5-develop.4516.1.88e6ab71a9",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.1",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.5-develop.4490.1.d8e873f3cf"
73
+ "@open-mercato/shared": "0.6.5-develop.4516.1.88e6ab71a9"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.5-develop.4490.1.d8e873f3cf",
76
+ "@open-mercato/shared": "0.6.5-develop.4516.1.88e6ab71a9",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -17,7 +17,10 @@ import {
17
17
  resolveAppReadyTimeoutMs,
18
18
  shouldReuseBuildArtifacts,
19
19
  acquireEphemeralRuntimeLock,
20
+ waitForApplicationReadiness,
20
21
  } from '../integration'
22
+ import { EventEmitter } from 'node:events'
23
+ import type { ChildProcess } from 'node:child_process'
21
24
 
22
25
  const CACHE_TTL_ENV_VAR = 'OM_INTEGRATION_BUILD_CACHE_TTL_SECONDS'
23
26
  const APP_READY_TIMEOUT_ENV_VAR = 'OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS'
@@ -556,3 +559,79 @@ describe('integration cache and options', () => {
556
559
  }
557
560
  })
558
561
  })
562
+
563
+ describe('waitForApplicationReadiness', () => {
564
+ const makeFakeProcess = (): ChildProcess => new EventEmitter() as unknown as ChildProcess
565
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
566
+
567
+ it('serializes probe cycles so slow probes never pile up concurrent login attempts', async () => {
568
+ let inFlight = 0
569
+ let maxInFlight = 0
570
+ let loginPageCycles = 0
571
+
572
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
573
+ const url = typeof input === 'string' ? input : String(input)
574
+ inFlight += 1
575
+ maxInFlight = Math.max(maxInFlight, inFlight)
576
+ try {
577
+ // Each probe fetch is slower than the retry interval; the old race-against-a-tick loop
578
+ // would launch overlapping cycles here and blow past 3 concurrent in-flight requests.
579
+ await sleep(40)
580
+ const isLoginPage = url.endsWith('/login') && !url.endsWith('/api/auth/login')
581
+ if (isLoginPage) {
582
+ loginPageCycles += 1
583
+ if (loginPageCycles <= 2) {
584
+ return { status: 503, ok: false, text: async () => '' } as unknown as Response
585
+ }
586
+ return {
587
+ status: 200,
588
+ ok: true,
589
+ text: async () => '<!doctype html><script src="/_next/static/chunks/app.js"></script>',
590
+ } as unknown as Response
591
+ }
592
+ if (url.endsWith('/api/auth/login')) {
593
+ return { status: 200, ok: true, text: async () => JSON.stringify({ token: 'token' }) } as unknown as Response
594
+ }
595
+ if (url.includes('/api/customers/people')) {
596
+ return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
597
+ }
598
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
599
+ } finally {
600
+ inFlight -= 1
601
+ }
602
+ })
603
+
604
+ try {
605
+ await waitForApplicationReadiness('http://127.0.0.1:5001', makeFakeProcess(), {
606
+ timeoutMs: 5_000,
607
+ intervalMs: 5,
608
+ stabilizationMs: 10,
609
+ })
610
+ // One cycle issues exactly three parallel probe fetches (login page, backend login,
611
+ // authenticated login). Serialized cycles keep the peak at three; overlap would exceed it.
612
+ expect(maxInFlight).toBeLessThanOrEqual(3)
613
+ expect(loginPageCycles).toBeGreaterThanOrEqual(3)
614
+ } finally {
615
+ fetchSpy.mockRestore()
616
+ }
617
+ })
618
+
619
+ it('fails fast when the application process exits before becoming ready', async () => {
620
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async () => {
621
+ await sleep(20)
622
+ return { status: 503, ok: false, text: async () => '' } as unknown as Response
623
+ })
624
+ const fakeProcess = makeFakeProcess()
625
+
626
+ try {
627
+ const readiness = waitForApplicationReadiness('http://127.0.0.1:5001', fakeProcess, {
628
+ timeoutMs: 5_000,
629
+ intervalMs: 5,
630
+ })
631
+ setTimeout(() => fakeProcess.emit('exit', 1), 30)
632
+ await expect(readiness).rejects.toThrow(/exited before readiness check \(exit 1\)/)
633
+ } finally {
634
+ fetchSpy.mockRestore()
635
+ }
636
+ })
637
+ })
@@ -8,6 +8,7 @@ import path from 'node:path'
8
8
  import { createInterface, type Interface } from 'node:readline/promises'
9
9
  import { stdin as input, stdout as output } from 'node:process'
10
10
  import spawn from 'cross-spawn'
11
+ import { fetchWithTimeout, type FetchWithTimeoutInit } from '@open-mercato/shared/lib/http/fetchWithTimeout'
11
12
  import { resolveEnvironment } from '../resolver'
12
13
  import { resolveSpawnCommand } from '../spawn'
13
14
  import { discoverIntegrationSpecFiles as discoverIntegrationSpecFilesShared } from './integration-discovery'
@@ -774,6 +775,14 @@ function delay(milliseconds: number): Promise<void> {
774
775
  })
775
776
  }
776
777
 
778
+ // Each readiness probe fetch is bounded so a single stuck connection cannot consume the whole
779
+ // readiness budget; on timeout it surfaces as a probe failure and the next cycle retries.
780
+ const READINESS_PROBE_FETCH_TIMEOUT_MS = 15_000
781
+
782
+ function probeFetch(input: string, init: FetchWithTimeoutInit = {}): Promise<Response> {
783
+ return fetchWithTimeout(input, { timeoutMs: READINESS_PROBE_FETCH_TIMEOUT_MS, ...init })
784
+ }
785
+
777
786
  export function resolveBuildCacheTtlSeconds(logPrefix: string): number {
778
787
  const rawValue = process.env[BUILD_CACHE_TTL_ENV_VAR]
779
788
  if (!rawValue) {
@@ -1263,7 +1272,7 @@ function isSuccessfulBrowserNavigationStatus(status: number): boolean {
1263
1272
 
1264
1273
  async function probeLoginPage(baseUrl: string): Promise<LoginPageProbeResult> {
1265
1274
  try {
1266
- const response = await fetch(`${baseUrl}/login`, {
1275
+ const response = await probeFetch(`${baseUrl}/login`, {
1267
1276
  method: 'GET',
1268
1277
  redirect: 'manual',
1269
1278
  })
@@ -1309,7 +1318,7 @@ async function probeBackendLoginEndpoint(baseUrl: string): Promise<BackendLoginP
1309
1318
  const form = new URLSearchParams()
1310
1319
  form.set('email', 'integration-healthcheck@example.invalid')
1311
1320
  form.set('password', 'invalid-password')
1312
- const response = await fetch(`${baseUrl}/api/auth/login`, {
1321
+ const response = await probeFetch(`${baseUrl}/api/auth/login`, {
1313
1322
  method: 'POST',
1314
1323
  redirect: 'manual',
1315
1324
  headers: {
@@ -1341,7 +1350,7 @@ async function probeAuthenticatedApi(baseUrl: string): Promise<AuthenticatedApiP
1341
1350
  const form = new URLSearchParams()
1342
1351
  form.set('email', 'admin@acme.com')
1343
1352
  form.set('password', 'secret')
1344
- const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
1353
+ const loginResponse = await probeFetch(`${baseUrl}/api/auth/login`, {
1345
1354
  method: 'POST',
1346
1355
  redirect: 'manual',
1347
1356
  headers: {
@@ -1372,7 +1381,7 @@ async function probeAuthenticatedApi(baseUrl: string): Promise<AuthenticatedApiP
1372
1381
  }
1373
1382
  }
1374
1383
 
1375
- const apiResponse = await fetch(`${baseUrl}/api/customers/people?pageSize=1`, {
1384
+ const apiResponse = await probeFetch(`${baseUrl}/api/customers/people?pageSize=1`, {
1376
1385
  headers: {
1377
1386
  Authorization: `Bearer ${token}`,
1378
1387
  },
@@ -1732,30 +1741,51 @@ export async function tryReuseExistingEnvironment(options: EphemeralRuntimeOptio
1732
1741
  }
1733
1742
  }
1734
1743
 
1735
- async function waitForApplicationReadiness(
1744
+ export async function waitForApplicationReadiness(
1736
1745
  baseUrl: string,
1737
1746
  appProcess: ChildProcess,
1738
- options: { timeoutMs: number },
1747
+ options: { timeoutMs: number; intervalMs?: number; stabilizationMs?: number },
1739
1748
  ): Promise<void> {
1740
1749
  const startTimestamp = Date.now()
1741
- const exitPromise = getProcessExitPromise(appProcess)
1742
- const readinessStabilizationMs = 600
1750
+ const intervalMs = options.intervalMs ?? APP_READY_INTERVAL_MS
1751
+ const readinessStabilizationMs = options.stabilizationMs ?? 600
1743
1752
  let lastProbe: ApplicationReadinessProbeResult | null = null
1744
1753
 
1754
+ // Wrap process exit into a single never-rejecting promise so we can race it without piling up
1755
+ // rejection handlers or losing the exit code across loop iterations.
1756
+ let exitCode: number | null = null
1757
+ const exitSignal = getProcessExitPromise(appProcess).then(
1758
+ (code) => {
1759
+ exitCode = code ?? null
1760
+ return { exited: true as const }
1761
+ },
1762
+ () => {
1763
+ exitCode = null
1764
+ return { exited: true as const }
1765
+ },
1766
+ )
1767
+ const exitError = () =>
1768
+ new Error(`Application process exited before readiness check (exit ${exitCode ?? 'unknown'})`)
1769
+
1745
1770
  while (Date.now() - startTimestamp < options.timeoutMs) {
1746
- const result = await Promise.race([
1747
- probeApplicationReadiness(baseUrl).then((probe) => ({ kind: 'probe' as const, probe })),
1748
- exitPromise.then((code) => ({ kind: 'exit' as const, code })),
1749
- delay(APP_READY_INTERVAL_MS).then(() => ({ kind: 'timeout' as const })),
1771
+ // Run one probe cycle to completion before starting the next. Overlapping cycles (the previous
1772
+ // race-against-a-1s-tick design) abandoned slow probes without cancelling them, so every second
1773
+ // a fresh /api/auth/login attempt piled onto the most expensive endpoint — amplifying the very
1774
+ // contention that delays readiness when 15 ephemeral shards boot in parallel. Each fetch is
1775
+ // independently bounded by fetchWithTimeout, so a stuck connection cannot stall the cycle.
1776
+ const cycle = await Promise.race([
1777
+ probeApplicationReadiness(baseUrl).then((probe) => ({ probe })),
1778
+ exitSignal,
1750
1779
  ])
1751
1780
 
1752
- if (result.kind === 'probe') {
1753
- lastProbe = result.probe
1754
- if (!result.probe.ready) {
1755
- continue
1756
- }
1781
+ if ('exited' in cycle) {
1782
+ throw exitError()
1783
+ }
1784
+
1785
+ lastProbe = cycle.probe
1786
+ if (cycle.probe.ready) {
1757
1787
  const processExited = await Promise.race([
1758
- exitPromise.then(() => true),
1788
+ exitSignal.then(() => true),
1759
1789
  delay(readinessStabilizationMs).then(() => false),
1760
1790
  ])
1761
1791
  if (processExited) {
@@ -1763,8 +1793,15 @@ async function waitForApplicationReadiness(
1763
1793
  }
1764
1794
  return
1765
1795
  }
1766
- if (result.kind === 'exit') {
1767
- throw new Error(`Application process exited before readiness check (exit ${result.code ?? 'unknown'})`)
1796
+
1797
+ const remainingMs = options.timeoutMs - (Date.now() - startTimestamp)
1798
+ if (remainingMs <= 0) break
1799
+ const waited = await Promise.race([
1800
+ exitSignal,
1801
+ delay(Math.min(intervalMs, remainingMs)).then(() => null),
1802
+ ])
1803
+ if (waited && 'exited' in waited) {
1804
+ throw exitError()
1768
1805
  }
1769
1806
  }
1770
1807