dify/cli/test/e2e/setup/global-setup.ts

707 lines
28 KiB
TypeScript

/**
* 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<void> {
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<string> {
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<void> {
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<boolean> {
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<string, string> = {}
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<string, string> = {}
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<void> {
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<Record<string, string>> {
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
const mkHeaders = (extra: Record<string, string> = {}): Record<string, string> => ({
'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<void> {
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<string | null> {
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<string> {
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<void> {
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<void> {
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<void> {
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<string, string> = {}
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<string> {
// 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<Response> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}