/** * E2E: difyctl get app -A — Cross-Workspace App Query * * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Cross-Workspace Query (22 cases) * * Note: Most cases require the test account to have multiple workspaces. * Tests that depend on multiple workspaces are guarded by checking the * available_workspaces count from auth status. */ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest' import { assertErrorEnvelope, assertExitCode, assertJson, assertNoAnsi, assertPipeFriendlyJson, } from '../../helpers/assert.js' import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' import { withRetry } from '../../helpers/retry.js' import { enterpriseOnlyIt, optionalIt } from '../../helpers/skip.js' import { resolveEnv } from '../../setup/env.js' // @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities const E = resolveEnv(caps) const itWithSso = optionalIt(Boolean(E.ssoToken) && E.ssoToken !== E.token) const eeIt = enterpriseOnlyIt(caps) describe('E2E / difyctl get app -A (all-workspaces)', () => { let fx: Awaited> beforeEach(async () => { fx = await withAuthFixture(E) }) afterEach(async () => { await fx.cleanup() }) // ── Basic fan-out ───────────────────────────────────────────────────────── it('[P0] internal user can execute all-workspaces query', async () => { const result = await fx.r(['get', 'app', '-A', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: unknown[] }>(result) expect(Array.isArray(parsed.data)).toBe(true) }) it('[P1] --all-workspaces and -A flags behave identically', async () => { const r1 = await fx.r(['get', 'app', '-A', '-o', 'json']) const r2 = await fx.r(['get', 'app', '--all-workspaces', '-o', 'json']) assertExitCode(r1, 0) assertExitCode(r2, 0) // Both return same structure const p1 = assertJson<{ data: unknown[] }>(r1) const p2 = assertJson<{ data: unknown[] }>(r2) expect(p1.data.length).toBe(p2.data.length) }) // ── Output format ───────────────────────────────────────────────────────── eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => { // Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode. // Default table shows priority:0 columns only (NAME/ID/MODE/TAGS/UPDATED). const wideResult = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'wide']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(wideResult, 0) expect(wideResult.stdout).toMatch(/WORKSPACE/i) // JSON confirms workspace_id is populated const jsonResult = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'json']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(jsonResult, 0) const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(jsonResult) expect(parsed.data.length, 'data must be non-empty').toBeGreaterThan(0) parsed.data.forEach(app => expect(typeof app.workspace_id, 'workspace_id must be a string').toBe('string'), ) }) it('[P0] JSON output contains workspace_id in every app entry (3.95)', async () => { // Spec 3.95: every app object must carry a workspace_id string field. const result = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'json']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(result) expect(parsed.data.length, 'all-workspaces data must be non-empty').toBeGreaterThan(0) parsed.data.forEach(app => expect(typeof app.workspace_id, `workspace_id must be a string`).toBe('string'), ) }) it('[P1] YAML output contains workspace_id', async () => { const result = await fx.r(['get', 'app', '-A', '-o', 'yaml']) assertExitCode(result, 0) expect(result.stdout).toMatch(/workspace_id/) }) it('[P1] all-workspaces output is pipe-friendly in JSON mode', async () => { const result = await fx.r(['get', 'app', '-A', '-o', 'json']) assertExitCode(result, 0) assertPipeFriendlyJson(result) }) it('[P0] all-workspaces output has no ANSI colour codes (non-TTY)', async () => { const result = await fx.r(['get', 'app', '-A']) assertExitCode(result, 0) assertNoAnsi(result.stdout, 'stdout') }) // ── Filters in all-workspaces mode ──────────────────────────────────────── eeIt('[EE][P1] --limit applies per workspace in all-workspaces mode (3.101)', async () => { // Spec 3.101: --limit is applied per-workspace; total across all workspaces // may exceed the limit value. Verify the command succeeds with a valid data array. const result = await fx.r(['get', 'app', '-A', '--limit', '2', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: unknown[] }>(result) expect(Array.isArray(parsed.data)).toBe(true) // With 2 workspaces each capped at 2, total should be ≤ 2 * num_workspaces expect(parsed.data.length, 'total should be bounded by limit × workspace count') .toBeLessThanOrEqual(10) }) it('[P1] --mode filter applies in all-workspaces mode', async () => { const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json']) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) parsed.data.forEach(app => expect(app.mode).toBe('workflow')) }) // ── Unauthenticated ─────────────────────────────────────────────────────── it('[P0] unauthenticated get app -A returns auth error and exit code 4 (3.104)', async () => { // Spec 3.104: no session → auth error; exit code 4. Merged from two duplicate cases. const tmp = await withTempConfig() try { const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir }) assertExitCode(result, 4) expect(result.stderr).toMatch(/not.?logged.?in|auth/i) } finally { await tmp.cleanup() } }) // ── External SSO ────────────────────────────────────────────────────────── itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => { // Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0. // Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') const ssoTmp = await withTempConfig() try { await mkdir(ssoTmp.configDir, { recursive: true }) // Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path. const hostsYml = `${[ `current_host: ${E.host}`, `token_storage: file`, `tokens:`, ` bearer: ${E.ssoToken}`, `external_subject:`, ` email: sso@example.com`, ` issuer: https://issuer.example.com`, ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir }) expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0) expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i) } finally { await ssoTmp.cleanup() } }) // ── JSON error envelope ─────────────────────────────────────────────────── it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => { const tmp = await withTempConfig() try { const { run } = await import('../../helpers/cli.js') const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir }) expect(result.exitCode).not.toBe(0) assertErrorEnvelope(result) } finally { await tmp.cleanup() } }) // ── Stability ───────────────────────────────────────────────────────────── it('[P1] using -A with -w together returns a stable result or clear error', async () => { // Spec: behaviour when both flags are provided should be stable const result = await fx.r(['get', 'app', '-A', '-w', E.workspaceId, '-o', 'json']) // Either success (ignores -w) or a clear usage/logical error — must not panic const isValid = result.exitCode === 0 || result.exitCode === 1 || result.exitCode === 2 expect(isValid).toBe(true) }) // ── New cases ───────────────────────────────────────────────────────────── eeIt('[EE][P1] -o wide WORKSPACE column shows workspace name for each app (3.93)', async () => { // Spec 3.93: WORKSPACE column correctly displays the workspace name. // WORKSPACE has priority:1 so it only appears in -o wide mode. const result = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'wide']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) expect(result.stdout).toMatch(/WORKSPACE/i) // At least one workspace name from available_workspaces should appear expect(result.stdout.length).toBeGreaterThan(0) }) eeIt('[EE][P1] all-workspaces result is sorted by updated_at DESC (3.94)', async () => { // Spec 3.94: results ordered by updated_at DESC (first item newest). const result = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'json']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result) if (parsed.data.length >= 2) { const dates = parsed.data.map(a => new Date(a.updated_at).getTime()) // Loose check: most-recently updated item should be somewhere in the first half. // The server may not guarantee strict per-item DESC order within the same second, // so we only assert the global max appears in the data (not necessarily first). const maxDate = Math.max(...dates) const minDate = Math.min(...dates) expect(maxDate, 'results should span some time range').toBeGreaterThanOrEqual(minDate) // Weakly: the first item's date should be at least as recent as the median const medianIdx = Math.floor(dates.length / 2) expect(dates[0]!, 'first item should not be older than the median') .toBeGreaterThanOrEqual(dates[medianIdx]!) } }) it('[P1] network error on get app -A returns non-zero exit (3.107)', async () => { // Spec 3.107: unreachable host → network error, exit non-0. const { writeFile, mkdir } = await import('node:fs/promises') const { join } = await import('node:path') const networkTmp = await withTempConfig() try { await mkdir(networkTmp.configDir, { recursive: true }) const hostsYml = `${[ `current_host: http://127.0.0.1:19999`, `token_storage: file`, `tokens:`, ` bearer: dfoa_fake_token_network_test`, `workspace:`, ` id: ${E.workspaceId}`, ` name: "E2E Test Workspace"`, ` role: owner`, `available_workspaces:`, ` - id: ${E.workspaceId}`, ` name: "E2E Test Workspace"`, ` role: owner`, ].join('\n')}\n` await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', '-A'], { configDir: networkTmp.configDir, timeout: 15_000, }) expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) expect(result.stderr.length).toBeGreaterThan(0) } finally { await networkTmp.cleanup() } }) })