@open-mercato/cli 0.6.5-develop.4477.1.7a250f91b8 → 0.6.5-develop.4498.1.55dc06a57c
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.
|
|
3
|
+
"version": "0.6.5-develop.4498.1.55dc06a57c",
|
|
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.
|
|
63
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
62
|
+
"@open-mercato/queue": "0.6.5-develop.4498.1.55dc06a57c",
|
|
63
|
+
"@open-mercato/shared": "0.6.5-develop.4498.1.55dc06a57c",
|
|
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.
|
|
73
|
+
"@open-mercato/shared": "0.6.5-develop.4498.1.55dc06a57c"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.5-develop.4498.1.55dc06a57c",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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 (
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
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
|
|