@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.
@@ -141,12 +141,13 @@ type EphemeralEnvironmentState = {
141
141
 
142
142
  type PlaywrightRunOptions = Pick<InteractiveIntegrationOptions, 'verbose' | 'captureScreenshots' | 'workers' | 'retries'>
143
143
 
144
- const APP_READY_TIMEOUT_MS = 90_000
144
+ const DEFAULT_APP_READY_TIMEOUT_MS = 90_000
145
145
  const APP_READY_INTERVAL_MS = 1_000
146
146
  const DEFAULT_EPHEMERAL_APP_PORT = 5001
147
147
  const EPHEMERAL_ENV_LOCK_TIMEOUT_MS = 60_000
148
148
  const EPHEMERAL_ENV_LOCK_POLL_MS = 500
149
149
  const DEFAULT_BUILD_CACHE_TTL_SECONDS = 600
150
+ const APP_READY_TIMEOUT_ENV_VAR = 'OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS'
150
151
  const BUILD_CACHE_TTL_ENV_VAR = 'OM_INTEGRATION_BUILD_CACHE_TTL_SECONDS'
151
152
  const PLAYWRIGHT_ENV_UNAVAILABLE_PATTERNS: RegExp[] = [
152
153
  /net::ERR_CONNECTION_REFUSED/i,
@@ -161,7 +162,6 @@ const PLAYWRIGHT_QUICK_FAILURE_THRESHOLD = 6
161
162
  const PLAYWRIGHT_QUICK_FAILURE_MAX_DURATION_MS = 1_500
162
163
  const PLAYWRIGHT_HEALTH_PROBE_INTERVAL_MS = 3_000
163
164
  const ANSI_ESCAPE_REGEX = /\x1b\[[0-?]*[ -/]*[@-~]/g // NOSONAR — ANSI escape sequence pattern
164
- const NEXT_STATIC_ASSET_PATTERN = /\/_next\/static\/[^"'`\s)]+?\.(?:js|css)/g
165
165
  const resolver = createResolver()
166
166
  const projectRootDirectory = resolver.getRootDir()
167
167
  const EPHEMERAL_ENV_FILE_PATH = path.join(projectRootDirectory, '.ai', 'qa', 'ephemeral-env.json')
@@ -250,6 +250,24 @@ type CommandMonitoringOptions = {
250
250
  playwrightFailureHealthCheck?: PlaywrightFailureHealthCheckOptions
251
251
  }
252
252
 
253
+ type LoginPageProbeResult = {
254
+ status: number | null
255
+ healthy: boolean
256
+ detail: string
257
+ }
258
+
259
+ type BackendLoginProbeResult = {
260
+ status: number | null
261
+ healthy: boolean
262
+ detail: string
263
+ }
264
+
265
+ type ApplicationReadinessProbeResult = {
266
+ ready: boolean
267
+ frontend: LoginPageProbeResult
268
+ backend: BackendLoginProbeResult
269
+ }
270
+
253
271
  type PlaywrightFailureHealthCheckOptions = {
254
272
  baseUrl: string
255
273
  consecutiveFailureThreshold?: number
@@ -678,6 +696,21 @@ export function resolveBuildCacheTtlSeconds(logPrefix: string): number {
678
696
  return parsed
679
697
  }
680
698
 
699
+ export function resolveAppReadyTimeoutMs(logPrefix: string): number {
700
+ const rawValue = process.env[APP_READY_TIMEOUT_ENV_VAR]
701
+ if (!rawValue) {
702
+ return DEFAULT_APP_READY_TIMEOUT_MS
703
+ }
704
+ const parsedSeconds = Number.parseInt(rawValue, 10)
705
+ if (!Number.isFinite(parsedSeconds) || parsedSeconds < 1) {
706
+ console.warn(
707
+ `[${logPrefix}] Invalid ${APP_READY_TIMEOUT_ENV_VAR} value "${rawValue}". Using default ${DEFAULT_APP_READY_TIMEOUT_MS / 1000}s.`,
708
+ )
709
+ return DEFAULT_APP_READY_TIMEOUT_MS
710
+ }
711
+ return parsedSeconds * 1000
712
+ }
713
+
681
714
  function buildCacheDefaults(overrides: BuildCacheOptions = {}): {
682
715
  artifactPaths: string[]
683
716
  inputPaths: string[]
@@ -1089,59 +1122,58 @@ export async function readEphemeralEnvironmentState(): Promise<EphemeralEnvironm
1089
1122
  }
1090
1123
  }
1091
1124
 
1092
- async function isApplicationReachable(baseUrl: string): Promise<boolean> {
1125
+ function isLoginHtmlHealthy(html: string): boolean {
1126
+ return !/Application error: a client-side exception has occurred/i.test(html)
1127
+ }
1128
+
1129
+ function isSuccessfulBrowserNavigationStatus(status: number): boolean {
1130
+ return status === 200 || (status >= 300 && status < 400)
1131
+ }
1132
+
1133
+ async function probeLoginPage(baseUrl: string): Promise<LoginPageProbeResult> {
1093
1134
  try {
1094
1135
  const response = await fetch(`${baseUrl}/login`, {
1095
1136
  method: 'GET',
1096
1137
  redirect: 'manual',
1097
1138
  })
1098
- if (response.status === 302) {
1099
- return true
1139
+ if (!isSuccessfulBrowserNavigationStatus(response.status)) {
1140
+ return {
1141
+ status: response.status,
1142
+ healthy: false,
1143
+ detail: `GET /login returned ${response.status}`,
1144
+ }
1100
1145
  }
1101
1146
  if (response.status !== 200) {
1102
- return false
1147
+ return {
1148
+ status: response.status,
1149
+ healthy: true,
1150
+ detail: `GET /login returned redirect ${response.status}`,
1151
+ }
1103
1152
  }
1104
- const html = await response.text()
1105
- return isLoginHtmlHealthy(html) && await areReferencedNextAssetsReachable(baseUrl, html)
1106
- } catch {
1107
- return false
1108
- }
1109
- }
1110
-
1111
- function isLoginHtmlHealthy(html: string): boolean {
1112
- return !/Application error: a client-side exception has occurred/i.test(html)
1113
- }
1114
-
1115
- function extractReferencedNextAssets(html: string, maxAssets = 8): string[] {
1116
- const matches = html.match(NEXT_STATIC_ASSET_PATTERN) ?? []
1117
- const unique = Array.from(new Set(matches))
1118
- return unique.slice(0, maxAssets)
1119
- }
1120
-
1121
- async function areReferencedNextAssetsReachable(baseUrl: string, html: string): Promise<boolean> {
1122
- const assets = extractReferencedNextAssets(html)
1123
- if (assets.length === 0) {
1124
- return false
1125
- }
1126
-
1127
- for (const assetPath of assets) {
1128
- try {
1129
- const response = await fetch(`${baseUrl}${assetPath}`, {
1130
- method: 'GET',
1131
- redirect: 'manual',
1132
- })
1133
- if (response.status !== 200 && response.status !== 304) {
1134
- return false
1153
+ const html = await response.text().catch(() => '')
1154
+ if (!isLoginHtmlHealthy(html)) {
1155
+ return {
1156
+ status: response.status,
1157
+ healthy: false,
1158
+ detail: 'GET /login returned client-side exception HTML',
1135
1159
  }
1136
- } catch {
1137
- return false
1160
+ }
1161
+ return {
1162
+ status: response.status,
1163
+ healthy: true,
1164
+ detail: 'GET /login returned healthy HTML',
1165
+ }
1166
+ } catch (error) {
1167
+ const message = error instanceof Error ? error.message : String(error)
1168
+ return {
1169
+ status: null,
1170
+ healthy: false,
1171
+ detail: `GET /login failed: ${message}`,
1138
1172
  }
1139
1173
  }
1140
-
1141
- return true
1142
1174
  }
1143
1175
 
1144
- async function isBackendLoginEndpointHealthy(baseUrl: string): Promise<boolean> {
1176
+ async function probeBackendLoginEndpoint(baseUrl: string): Promise<BackendLoginProbeResult> {
1145
1177
  try {
1146
1178
  const form = new URLSearchParams()
1147
1179
  form.set('email', 'integration-healthcheck@example.invalid')
@@ -1155,9 +1187,34 @@ async function isBackendLoginEndpointHealthy(baseUrl: string): Promise<boolean>
1155
1187
  body: form.toString(),
1156
1188
  })
1157
1189
 
1158
- return response.status === 200 || response.status === 400 || response.status === 401 || response.status === 403
1159
- } catch {
1160
- return false
1190
+ const healthy = response.status === 200 || response.status === 400 || response.status === 401 || response.status === 403
1191
+ return {
1192
+ status: response.status,
1193
+ healthy,
1194
+ detail: healthy
1195
+ ? `POST /api/auth/login returned ${response.status}`
1196
+ : `POST /api/auth/login returned unexpected ${response.status}`,
1197
+ }
1198
+ } catch (error) {
1199
+ const message = error instanceof Error ? error.message : String(error)
1200
+ return {
1201
+ status: null,
1202
+ healthy: false,
1203
+ detail: `POST /api/auth/login failed: ${message}`,
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ async function probeApplicationReadiness(baseUrl: string): Promise<ApplicationReadinessProbeResult> {
1209
+ const [frontend, backend] = await Promise.all([
1210
+ probeLoginPage(baseUrl),
1211
+ probeBackendLoginEndpoint(baseUrl),
1212
+ ])
1213
+
1214
+ return {
1215
+ ready: frontend.healthy && backend.healthy,
1216
+ frontend,
1217
+ backend,
1161
1218
  }
1162
1219
  }
1163
1220
 
@@ -1266,8 +1323,11 @@ function buildReusableEnvironment(baseUrl: string, captureScreenshots: boolean):
1266
1323
  return buildEnvironment({
1267
1324
  BASE_URL: baseUrl,
1268
1325
  NODE_ENV: 'production',
1326
+ JWT_SECRET: process.env.JWT_SECRET ?? 'om-ephemeral-integration-jwt-secret',
1327
+ OM_SECURITY_MFA_SETUP_SECRET: process.env.OM_SECURITY_MFA_SETUP_SECRET ?? 'om-ephemeral-integration-mfa-setup-secret',
1269
1328
  OM_ENABLE_ENTERPRISE_MODULES: process.env.OM_ENABLE_ENTERPRISE_MODULES ?? 'false',
1270
1329
  OM_ENABLE_ENTERPRISE_MODULES_SSO: process.env.OM_ENABLE_ENTERPRISE_MODULES_SSO ?? 'false',
1330
+ OM_ENABLE_ENTERPRISE_MODULES_SECURITY: process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY ?? 'false',
1271
1331
  OM_TEST_MODE: '1',
1272
1332
  ENABLE_CRUD_API_CACHE: 'true',
1273
1333
  NEXT_PUBLIC_OM_EXAMPLE_INJECTION_WIDGETS_ENABLED: 'true',
@@ -1338,46 +1398,27 @@ export async function tryReuseExistingEnvironment(options: EphemeralRuntimeOptio
1338
1398
  }
1339
1399
  }
1340
1400
 
1341
- async function waitForApplicationReadiness(baseUrl: string, appProcess: ChildProcess): Promise<void> {
1401
+ async function waitForApplicationReadiness(
1402
+ baseUrl: string,
1403
+ appProcess: ChildProcess,
1404
+ options: { timeoutMs: number },
1405
+ ): Promise<void> {
1342
1406
  const startTimestamp = Date.now()
1343
1407
  const exitPromise = getProcessExitPromise(appProcess)
1344
1408
  const readinessStabilizationMs = 600
1409
+ let lastProbe: ApplicationReadinessProbeResult | null = null
1345
1410
 
1346
- while (Date.now() - startTimestamp < APP_READY_TIMEOUT_MS) {
1347
- const responsePromise = fetch(`${baseUrl}/login`, {
1348
- method: 'GET',
1349
- redirect: 'manual',
1350
- })
1351
- .then(async (response) => ({
1352
- response,
1353
- body: response.status === 200 ? await response.text().catch(() => '') : '',
1354
- }))
1355
- .catch(() => null)
1411
+ while (Date.now() - startTimestamp < options.timeoutMs) {
1356
1412
  const result = await Promise.race([
1357
- responsePromise.then((payload) => {
1358
- if (!payload) {
1359
- return { kind: 'network_error' as const }
1360
- }
1361
- return {
1362
- kind: 'response' as const,
1363
- status: payload.response.status,
1364
- body: payload.body,
1365
- }
1366
- }),
1413
+ probeApplicationReadiness(baseUrl).then((probe) => ({ kind: 'probe' as const, probe })),
1367
1414
  exitPromise.then((code) => ({ kind: 'exit' as const, code })),
1368
1415
  delay(APP_READY_INTERVAL_MS).then(() => ({ kind: 'timeout' as const })),
1369
1416
  ])
1370
1417
 
1371
- if (result.kind === 'response' && (result.status === 200 || result.status === 302)) {
1372
- if (result.status === 200) {
1373
- const loginHtml = result.body ?? ''
1374
- if (!isLoginHtmlHealthy(loginHtml)) {
1375
- continue
1376
- }
1377
- const assetsReachable = await areReferencedNextAssetsReachable(baseUrl, loginHtml)
1378
- if (!assetsReachable) {
1379
- continue
1380
- }
1418
+ if (result.kind === 'probe') {
1419
+ lastProbe = result.probe
1420
+ if (!result.probe.ready) {
1421
+ continue
1381
1422
  }
1382
1423
  const processExited = await Promise.race([
1383
1424
  exitPromise.then(() => true),
@@ -1393,7 +1434,11 @@ async function waitForApplicationReadiness(baseUrl: string, appProcess: ChildPro
1393
1434
  }
1394
1435
  }
1395
1436
 
1396
- throw new Error(`Application did not become ready within ${APP_READY_TIMEOUT_MS / 1000} seconds`)
1437
+ const lastFrontendDetail = lastProbe?.frontend.detail ?? 'GET /login was never observed'
1438
+ const lastBackendDetail = lastProbe?.backend.detail ?? 'POST /api/auth/login was never observed'
1439
+ throw new Error(
1440
+ `Application did not become ready within ${options.timeoutMs / 1000} seconds. Last probe: ${lastFrontendDetail}; ${lastBackendDetail}`,
1441
+ )
1397
1442
  }
1398
1443
 
1399
1444
  export function parseOptions(rawArgs: string[]): IntegrationOptions {
@@ -2304,11 +2349,8 @@ async function runIntegrationTestSuiteOnce(
2304
2349
  }
2305
2350
 
2306
2351
  async function isEnvironmentUnavailable(baseUrl: string): Promise<boolean> {
2307
- const [applicationReachable, backendHealthy] = await Promise.all([
2308
- isApplicationReachable(baseUrl),
2309
- isBackendLoginEndpointHealthy(baseUrl),
2310
- ])
2311
- return !applicationReachable || !backendHealthy
2352
+ const readiness = await probeApplicationReadiness(baseUrl)
2353
+ return !readiness.ready
2312
2354
  }
2313
2355
 
2314
2356
  function isEnvironmentUnavailableError(error: unknown): boolean {
@@ -2496,7 +2538,8 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2496
2538
  const commandEnvironment = buildEnvironment({
2497
2539
  DATABASE_URL: databaseUrl,
2498
2540
  BASE_URL: applicationBaseUrl,
2499
- JWT_SECRET: 'om-ephemeral-integration-jwt-secret',
2541
+ JWT_SECRET: process.env.JWT_SECRET ?? 'om-ephemeral-integration-jwt-secret',
2542
+ OM_SECURITY_MFA_SETUP_SECRET: process.env.OM_SECURITY_MFA_SETUP_SECRET ?? 'om-ephemeral-integration-mfa-setup-secret',
2500
2543
  NODE_ENV: 'production',
2501
2544
  DB_POOL_MIN: '0',
2502
2545
  DB_POOL_MAX: '5',
@@ -2506,6 +2549,7 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2506
2549
  DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: '30000',
2507
2550
  OM_ENABLE_ENTERPRISE_MODULES: process.env.OM_ENABLE_ENTERPRISE_MODULES ?? 'false',
2508
2551
  OM_ENABLE_ENTERPRISE_MODULES_SSO: process.env.OM_ENABLE_ENTERPRISE_MODULES_SSO ?? 'false',
2552
+ OM_ENABLE_ENTERPRISE_MODULES_SECURITY: process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY ?? 'false',
2509
2553
  OM_TEST_MODE: '1',
2510
2554
  OM_TEST_AUTH_RATE_LIMIT_MODE: 'opt-in',
2511
2555
  OM_DISABLE_EMAIL_DELIVERY: '1',
@@ -2537,6 +2581,7 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2537
2581
  }
2538
2582
 
2539
2583
  try {
2584
+ const appReadyTimeoutMs = resolveAppReadyTimeoutMs(options.logPrefix)
2540
2585
  const buildCacheTtlSeconds = resolveBuildCacheTtlSeconds(options.logPrefix)
2541
2586
  let sourceFingerprintValue: string | null = null
2542
2587
  let needsBuild = true
@@ -2614,8 +2659,15 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2614
2659
  })
2615
2660
  applicationProcess = startedAppProcess
2616
2661
 
2617
- await runTimedStep(options.logPrefix, 'Waiting for application readiness', { expectedSeconds: 12 }, async () =>
2618
- waitForApplicationReadiness(applicationBaseUrl, startedAppProcess))
2662
+ await runTimedStep(
2663
+ options.logPrefix,
2664
+ 'Waiting for application readiness',
2665
+ { expectedSeconds: Math.max(12, Math.ceil(appReadyTimeoutMs / 1000)) },
2666
+ async () =>
2667
+ waitForApplicationReadiness(applicationBaseUrl, startedAppProcess, {
2668
+ timeoutMs: appReadyTimeoutMs,
2669
+ }),
2670
+ )
2619
2671
  console.log(`[${options.logPrefix}] Application is ready at ${applicationBaseUrl}`)
2620
2672
  await writeEphemeralEnvironmentState({
2621
2673
  baseUrl: applicationBaseUrl,