/** * Vitest global setup — runs once before all E2E suites. * * ── CE path (DIFY_E2E_EDITION=ce or unset) ─────────────────────────────── * 1. Register a new account with EMAIL/PASSWORD (idempotent). * 2. Login to obtain a session cookie. * 3. Mint the primary bearer token via the device flow. * 4. Validate the token. * 5. Discover the single workspace (falls back to first available). * 6. Mint per-suite dedicated tokens (logout / devices suites). * 7. Import all DSL fixtures into the workspace, publish & set public. * * ── EE path (DIFY_E2E_EDITION=ee) ──────────────────────────────────────── * Workspaces are pre-created by the operator and must be named: * primary → "auto_test0" * secondary → "auto_test1" * * 1. Login with EMAIL/PASSWORD to obtain a session cookie. * 2. Mint the primary bearer token via the device flow. * 3. Validate the token. * 4. Discover "auto_test0" (primary) and "auto_test1" (secondary) workspaces. * 5. Mint per-suite dedicated tokens. * 6. Import DSL fixtures into primary workspace; import ws2-workflow.yml * into the secondary workspace. Publish & set access_mode → public. */ import type { TestProject } from 'vitest/node' import type { E2ECapabilities } from './env.js' import { Buffer } from 'node:buffer' import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { loadE2EEnv } from './env.js' const TOKEN_MINT_APPROVE_ATTEMPTS = 5 const TOKEN_MINT_RETRY_BASE_MS = 2_000 export async function setup(project: TestProject): Promise { if (process.env.DIFY_E2E_MODE === 'local') return const E = loadE2EEnv() const consoleBase = E.consoleUrl.replace(/\/$/, '') const apiBase = E.host.replace(/\/$/, '') console.warn(`[E2E global-setup] Edition: ${E.edition.toUpperCase()}`) // ── Account bootstrap ──────────────────────────────────────────────────── if (E.edition === 'ce') { await ceRegisterAccount(consoleBase, E.email, E.password) } // EE: account & workspaces are pre-provisioned by the operator — just login. // ── Login ──────────────────────────────────────────────────────────────── const { cookieString, csrfToken } = await consoleLogin(consoleBase, E.email, E.password) // ── Mint primary token (with local cache to avoid rate-limit) ────────── // Priority: DIFY_E2E_TOKEN env → .token-cache.json → fresh mint // The cache file lives next to .env.e2e and is git-ignored. // logoutToken/devicesToken are intentionally NOT cached — those suites // revoke their token, so they always need a fresh one. const TOKEN_CACHE = join(process.cwd(), '.token-cache.json') async function loadCachedToken(): Promise { try { const raw = await readFile(TOKEN_CACHE, 'utf8') const { token, host } = JSON.parse(raw) as { token?: string, host?: string } // Invalidate if host changed (different staging env) if (!token || host !== E.host) return '' return token } catch { return '' } } async function saveCachedToken(token: string): Promise { try { await writeFile(TOKEN_CACHE, JSON.stringify({ token, host: E.host }, null, 2), 'utf8') } catch (err) { console.warn(`[E2E] Could not save token cache: ${err}`) } } async function validateToken(token: string): Promise { try { const r = await fetch(`${apiBase}/openapi/v1/account/sessions?page=1&limit=100`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8_000), }) return r.ok } catch { return false } } let primaryToken = E.token if (primaryToken) { console.warn(`[E2E] primaryToken from env: ${primaryToken.slice(0, 20)}…`) } else { // Try cache first const cached = await loadCachedToken() if (cached && await validateToken(cached)) { primaryToken = cached console.warn(`[E2E] primaryToken from cache: ${primaryToken.slice(0, 20)}…`) } else { if (cached) console.warn('[E2E] Cached token invalid or expired — re-minting…') try { primaryToken = await mintTokenWithSession(consoleBase, cookieString, csrfToken, 'e2e-primary') await saveCachedToken(primaryToken) console.warn(`[E2E] primaryToken minted and cached: ${primaryToken.slice(0, 20)}…`) } catch (err) { throw new Error( `[E2E global-setup] Failed to mint primary token: ${err}\n` + 'Ensure DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD are correct.', ) } } } // ── Validate primary token ─────────────────────────────────────────────── const sessionsUrl = `${apiBase}/openapi/v1/account/sessions?page=1&limit=100` let res: Response try { res = await fetch(sessionsUrl, { headers: { Authorization: `Bearer ${primaryToken}` }, signal: AbortSignal.timeout(10_000), }) } catch (err) { throw new Error( `[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n` + `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`, ) } if (!res.ok) { throw new Error( `[E2E global-setup] Primary token is invalid or expired (HTTP ${res.status}).\n` + `URL: ${sessionsUrl}`, ) } console.warn(`[E2E] Server healthy, primary token valid at ${E.host}`) // ── Resolve token_id ───────────────────────────────────────────────────── const body = await res.json() as { data: Array<{ id: string, prefix: string }> } const match = body.data.find(s => s.prefix !== '' && primaryToken.startsWith(s.prefix)) if (!match) { console.warn('[E2E global-setup] Could not resolve token_id — devicesToken selfHit detection may not work') } else { console.warn(`[E2E] Resolved token_id: ${match.id}`) } // ── Discover workspaces ────────────────────────────────────────────────── const workspaces = await discoverWorkspaces( consoleBase, cookieString, csrfToken, E.edition, ) if (!workspaces) { // @ts-expect-error — ProvidedContext augmentation cannot be expressed without // triggering TS2300 or TS2664 under tsgo; safe at runtime. project.provide('e2eCapabilities', { tokenValid: true, tokenId: match?.id, edition: E.edition, token: primaryToken, logoutToken: '', devicesToken: '', workspaceId: '', workspaceName: '', ws2Id: '', chatAppId: '', workflowAppId: '', fileAppId: '', fileChatAppId: '', hitlAppId: '', hitlExternalAppId: '', hitlSingleActionAppId: '', hitlMultiNodeAppId: '', ws2AppId: '', } satisfies E2ECapabilities) return } const { primaryWsId, primaryWsName, secondaryWsId } = workspaces // ── Mint per-suite dedicated tokens ────────────────────────────────────── let logoutToken = '' let devicesToken = '' const mint = (label: string) => mintTokenWithSession(consoleBase, cookieString, csrfToken, label) const [lt, dt] = await Promise.allSettled([ mint('e2e-logout-suite'), mint('e2e-devices-suite'), ]) if (lt.status === 'fulfilled') { logoutToken = lt.value console.warn(`[E2E] logoutToken minted: ${logoutToken.slice(0, 20)}…`) } else { console.warn(`[E2E global-setup] Failed to mint logoutToken: ${lt.reason}`) } if (dt.status === 'fulfilled') { devicesToken = dt.value console.warn(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}…`) } else { console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`) } // ── Provision fixture apps ─────────────────────────────────────────────── // Skip provisionApps when app IDs are already injected via DIFY_E2E_*_APP_ID // environment variables (e.g. from the CI provision job). Running provisionApps // in every parallel suite job causes race conditions: multiple jobs query // findAppByName simultaneously, all get "not found", then each imports the DSL // independently — creating duplicate apps per workspace. let provisionedIds: Record = {} const preProvisioned = [ 'DIFY_E2E_CHAT_APP_ID', 'DIFY_E2E_WORKFLOW_APP_ID', 'DIFY_E2E_FILE_APP_ID', 'DIFY_E2E_FILE_CHAT_APP_ID', 'DIFY_E2E_HITL_APP_ID', 'DIFY_E2E_HITL_EXTERNAL_APP_ID', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', 'DIFY_E2E_WS2_APP_ID', ] const envAppIds: Record = {} for (const key of preProvisioned) { const val = process.env[key] if (val && val !== '') envAppIds[key] = val } const allPreset = preProvisioned.every(k => envAppIds[k] !== undefined) if (allPreset) { // All app IDs already available via env — skip provisioning to avoid // race conditions in parallel CI jobs. provisionedIds = envAppIds console.warn(`[E2E global-setup] App IDs pre-set via env — skipping provisionApps (${Object.keys(provisionedIds).length} apps)`) } else { try { const fixturesDir = join(fileURLToPath(import.meta.url), '..', '..', 'fixtures', 'apps') provisionedIds = await provisionApps( consoleBase, cookieString, csrfToken, primaryWsId, secondaryWsId, fixturesDir, E.edition, ) console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`) } catch (err) { console.warn(`[E2E global-setup] provisionApps failed (non-fatal): ${err}`) } } // ── Provide capabilities ───────────────────────────────────────────────── const capabilities: E2ECapabilities = { tokenValid: true, tokenId: match?.id, edition: E.edition, token: primaryToken, logoutToken, devicesToken, workspaceId: primaryWsId, workspaceName: primaryWsName, ws2Id: secondaryWsId, chatAppId: provisionedIds.DIFY_E2E_CHAT_APP_ID || E.chatAppId, workflowAppId: provisionedIds.DIFY_E2E_WORKFLOW_APP_ID || E.workflowAppId, fileAppId: provisionedIds.DIFY_E2E_FILE_APP_ID || E.fileAppId, fileChatAppId: provisionedIds.DIFY_E2E_FILE_CHAT_APP_ID || E.fileChatAppId, hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId, hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId, hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId, hitlMultiNodeAppId: provisionedIds.DIFY_E2E_HITL_MULTI_NODE_APP_ID || E.hitlMultiNodeAppId, ws2AppId: provisionedIds.DIFY_E2E_WS2_APP_ID || E.ws2AppId, } // @ts-expect-error — ProvidedContext augmentation cannot be expressed without // triggering TS2300 or TS2664 under tsgo; safe at runtime. project.provide('e2eCapabilities', capabilities) } export { teardown } from './global-teardown.js' // ══════════════════════════════════════════════════════════════════════════════ // CE — account registration // ══════════════════════════════════════════════════════════════════════════════ /** * Register a CE account idempotently. * Tries /init (fresh server) first, then falls back to /register. * A 409 "already exists" response is treated as success. */ async function ceRegisterAccount(consoleBase: string, email: string, password: string): Promise { const passwordB64 = Buffer.from(password, 'utf8').toString('base64') const name = email.split('@')[0] ?? 'e2e-user' const initRes = await fetch(`${consoleBase}/console/api/init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, name, password: passwordB64 }), signal: AbortSignal.timeout(15_000), }) if (initRes.ok || initRes.status === 409) { console.warn(`[E2E CE] Account ready via /init (status ${initRes.status})`) return } const registerRes = await fetch(`${consoleBase}/console/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, name, password: passwordB64 }), signal: AbortSignal.timeout(15_000), }) if (!registerRes.ok && registerRes.status !== 409) { console.warn( `[E2E CE] /register returned HTTP ${registerRes.status} — account may already exist; continuing`, ) } else { console.warn(`[E2E CE] Account ready via /register (status ${registerRes.status})`) } } // ══════════════════════════════════════════════════════════════════════════════ // Shared helpers // ══════════════════════════════════════════════════════════════════════════════ // ── Console login ───────────────────────────────────────────────────────── async function consoleLogin( consoleBase: string, email: string, password: string, ): Promise<{ cookieString: string, csrfToken: string }> { const passwordB64 = Buffer.from(password, 'utf8').toString('base64') const loginRes = await fetch(`${consoleBase}/console/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: passwordB64, remember_me: false }), signal: AbortSignal.timeout(15_000), }) if (!loginRes.ok) throw new Error(`console/api/login failed: HTTP ${loginRes.status}`) const setCookies = loginRes.headers.getSetCookie?.() ?? [] const cookieString = setCookies.map(c => c.split(';')[0]).join('; ') const csrfToken = (cookieString.match(/csrf_token=([^;]+)/) ?? [])[1] ?? '' return { cookieString, csrfToken } } // ── Workspace discovery ─────────────────────────────────────────────────── /** * Discover primary and secondary workspaces. * * CE: looks for any workspace with "auto" in its name; falls back to the * first available workspace. secondaryWsId === primaryWsId when only * one workspace exists. * * EE: looks for workspaces named exactly "auto_test0" (primary) and * "auto_test1" (secondary). These must be pre-created by the operator. * Throws if "auto_test0" is not found. */ async function discoverWorkspaces( consoleBase: string, cookieString: string, csrfToken: string, edition: 'ce' | 'ee', ): Promise<{ primaryWsId: string, primaryWsName: string, secondaryWsId: string } | null> { const wsRes = await fetch(`${consoleBase}/console/api/workspaces`, { headers: { 'Cookie': cookieString, 'X-CSRF-Token': csrfToken }, signal: AbortSignal.timeout(10_000), }) if (!wsRes.ok) throw new Error(`list workspaces failed: HTTP ${wsRes.status}`) const wsBody = await wsRes.json() as { workspaces?: Array<{ id: string, name: string }> } const all = wsBody.workspaces ?? [] if (edition === 'ee') { // EE: must find the two pre-created workspaces by exact name const ws0 = all.find(w => w.name === 'auto_test0') const ws1 = all.find(w => w.name === 'auto_test1') if (!ws0 || !ws1) { const existing = all.map(w => w.name).join(', ') || '(none)' console.warn( `[E2E EE] Required workspaces not found; expected auto_test0 and auto_test1, got: ${existing}. ` + 'Skip fixture app provisioning.', ) return null } const primaryWsId = ws0.id const primaryWsName = ws0.name const secondaryWsId = ws1.id console.warn(`[E2E EE] primary workspace: ${primaryWsName} (${primaryWsId})`) console.warn(`[E2E EE] secondary workspace: ${ws1.name} (${secondaryWsId})`) return { primaryWsId, primaryWsName, secondaryWsId } } // CE: look for workspaces with "auto" in the name, sorted alphabetically const autoWorkspaces = all .filter(w => w.name.toLowerCase().includes('auto')) .sort((a, b) => a.name.localeCompare(b.name)) if (autoWorkspaces.length > 0) { const primaryWsId = autoWorkspaces[0]!.id const primaryWsName = autoWorkspaces[0]!.name const secondaryWsId = autoWorkspaces[1]?.id ?? primaryWsId console.warn(`[E2E CE] primary workspace: ${primaryWsName} (${primaryWsId})`) if (autoWorkspaces[1]) console.warn(`[E2E CE] secondary workspace: ${autoWorkspaces[1].name} (${secondaryWsId})`) else console.warn('[E2E CE] only one "auto" workspace found — ws2 reuses primary') return { primaryWsId, primaryWsName, secondaryWsId } } // CE fallback: use the first available workspace if (all.length === 0) throw new Error('[E2E CE] No workspaces found for this account') const primaryWsId = all[0]!.id const primaryWsName = all[0]!.name console.warn(`[E2E CE] primary workspace (fallback): ${primaryWsName} (${primaryWsId})`) return { primaryWsId, primaryWsName, secondaryWsId: primaryWsId } } // ── App provisioning ────────────────────────────────────────────────────── /** * Idempotently provision all E2E fixture apps. * * CE: imports all primary-workspace fixtures; skips ws2-workflow.yml * (no real secondary workspace). * * EE: imports primary-workspace fixtures into auto_test0, and * ws2-workflow.yml into auto_test1. * * Per app: * 1. Switch to the target workspace * 2. Search by app name — reuse existing app when found * 3. If not found → import from DSL file * 4. Enable Service API * 5. Publish (workflow / advanced-chat / agent-chat only) * 6. Set access_mode → public */ async function provisionApps( consoleBase: string, cookieString: string, csrfToken: string, primaryWsId: string, secondaryWsId: string, fixturesDir: string, edition: 'ce' | 'ee', ): Promise> { const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat']) const mkHeaders = (extra: Record = {}): Record => ({ 'Cookie': cookieString, 'X-CSRF-Token': csrfToken, ...extra, }) // ws2-workflow.yml is only provisioned in EE mode (real secondary workspace) const APP_SPECS: Array<[string, string, string]> = [ ['echo-chat.yml', 'DIFY_E2E_CHAT_APP_ID', primaryWsId], ['echo-workflow.yml', 'DIFY_E2E_WORKFLOW_APP_ID', primaryWsId], ['file-upload.yml', 'DIFY_E2E_FILE_APP_ID', primaryWsId], ['hitl-main.yml', 'DIFY_E2E_HITL_APP_ID', primaryWsId], ['hitl-external.yml', 'DIFY_E2E_HITL_EXTERNAL_APP_ID', primaryWsId], ['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId], ['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId], ['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId], ...(edition === 'ee' ? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]] : []), ] async function switchWorkspace(wsId: string): Promise { const r = await fetch(`${consoleBase}/console/api/workspaces/switch`, { method: 'POST', headers: mkHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ tenant_id: wsId }), signal: AbortSignal.timeout(10_000), }) if (!r.ok) throw new Error(`workspace switch to ${wsId} failed: HTTP ${r.status}`) } async function findAppByName(name: string): Promise { const url = `${consoleBase}/console/api/apps?name=${encodeURIComponent(name)}&limit=50&page=1` const r = await fetch(url, { headers: mkHeaders(), signal: AbortSignal.timeout(10_000) }) if (!r.ok) throw new Error(`list apps by name "${name}" failed: HTTP ${r.status}`) const d = await r.json() as { data?: Array<{ id: string, name: string }> } return d.data?.find(a => a.name === name)?.id ?? null } async function importFromDsl(yamlContent: string): Promise { const r = await fetch(`${consoleBase}/console/api/apps/imports`, { method: 'POST', headers: mkHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ mode: 'yaml-content', yaml_content: yamlContent }), signal: AbortSignal.timeout(30_000), }) const d = await r.json() as { app_id?: string, import_id?: string, status?: string } if (r.status === 202 && d.import_id) { const cr = await fetch(`${consoleBase}/console/api/apps/imports/${d.import_id}/confirm`, { method: 'POST', headers: mkHeaders(), signal: AbortSignal.timeout(15_000), }) const c = await cr.json() as { app_id?: string } if (!c.app_id) throw new Error(`import confirm failed: HTTP ${cr.status}`) return c.app_id } if (!d.app_id) throw new Error(`import failed: HTTP ${r.status} ${JSON.stringify(d)}`) return d.app_id } async function enableApi(appId: string): Promise { await fetch(`${consoleBase}/console/api/apps/${appId}/api-enable`, { method: 'POST', headers: mkHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ enable_api: true }), signal: AbortSignal.timeout(10_000), }) } async function publishWorkflow(appId: string): Promise { await fetch(`${consoleBase}/console/api/apps/${appId}/workflows/publish`, { method: 'POST', headers: mkHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ marked_name: 'e2e-provision', marked_comment: '' }), signal: AbortSignal.timeout(20_000), }) } async function setAppPublic(appId: string): Promise { try { const r = await fetch(`${consoleBase}/console/api/enterprise/webapp/app/access-mode`, { method: 'POST', headers: mkHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ appId, accessMode: 'public' }), signal: AbortSignal.timeout(10_000), }) if (r.ok) { console.warn(`[E2E provision] setAppPublic(${appId}): access_mode → public`) } else { // CE servers return 404 here — non-fatal const text = await r.text().catch(() => '') console.warn(`[E2E provision] setAppPublic(${appId}) skipped: HTTP ${r.status} ${text.slice(0, 100)}`) } } catch (err) { console.warn(`[E2E provision] setAppPublic(${appId}) error (non-fatal): ${err}`) } } const results: Record = {} let currentWs = '' for (const [dslFile, envVar, wsId] of APP_SPECS) { try { if (wsId !== currentWs) { await switchWorkspace(wsId) currentWs = wsId } const dsl = await readFile(join(fixturesDir, dslFile), 'utf8') const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1] ?.trim() .replace(/^['"]|['"]$/g, '') ?? dslFile const appMode = (dsl.match(/^\s+mode:\s*(\S+)/m) ?? [])[1] ?? '' let appId = await findAppByName(appName) if (appId) { console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`) } else { appId = await importFromDsl(dsl) console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`) } await enableApi(appId) await setAppPublic(appId) if (NEEDS_PUBLISH.has(appMode)) await publishWorkflow(appId) results[envVar] = appId } catch (err) { console.warn(`[E2E provision] ${dslFile} skipped: ${err}`) } } return results } // ── Token minting via device flow ───────────────────────────────────────── async function mintTokenWithSession( consoleBase: string, cookieString: string, csrfToken: string, label: string, ): Promise { // Step 1 — request device code const codeRes = await fetch(`${consoleBase}/openapi/v1/oauth/device/code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: 'difyctl', device_label: label }), signal: AbortSignal.timeout(15_000), }) if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`) const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string } // Step 2 — approve const approveRes = await approveDeviceCodeWithRetry({ consoleBase, cookieString, csrfToken, userCode: user_code, }) if (!approveRes.ok) throw new Error(`device/approve failed: HTTP ${approveRes.status}`) // Step 3 — exchange for bearer token const tokenRes = await fetch(`${consoleBase}/openapi/v1/oauth/device/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code, client_id: 'difyctl' }), signal: AbortSignal.timeout(10_000), }) if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`) const tokenBody = await tokenRes.json() as { token?: string, error?: string } if (!tokenBody.token) throw new Error(`device/token response missing token: ${JSON.stringify(tokenBody)}`) return tokenBody.token } async function approveDeviceCodeWithRetry(opts: { readonly consoleBase: string readonly cookieString: string readonly csrfToken: string readonly userCode: string }): Promise { let lastResponse: Response | undefined for (let attempt = 1; attempt <= TOKEN_MINT_APPROVE_ATTEMPTS; attempt++) { const response = await fetch(`${opts.consoleBase}/openapi/v1/oauth/device/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': opts.cookieString, 'X-CSRFToken': opts.csrfToken, }, body: JSON.stringify({ user_code: opts.userCode }), signal: AbortSignal.timeout(10_000), }) if (response.ok || !isRetryableApproveStatus(response.status)) return response lastResponse = response const delayMs = TOKEN_MINT_RETRY_BASE_MS * attempt console.warn(`[E2E] device approve HTTP ${response.status}; retrying in ${delayMs}ms (${attempt}/${TOKEN_MINT_APPROVE_ATTEMPTS})`) await sleep(delayMs) } return lastResponse ?? new Response(null, { status: 429 }) } function isRetryableApproveStatus(status: number): boolean { return status === 429 || status >= 500 } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) }