Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getRun = vi.fn();

vi.mock('workflow/api', () => ({
getRun: (...args: unknown[]) => getRun(...args),
}));

function runHandle(overrides: {
exists?: boolean;
status?: string;
returnValue?: Promise<unknown>;
}) {
return {
exists: Promise.resolve(overrides.exists ?? true),
status: Promise.resolve(overrides.status ?? 'completed'),
workflowName: Promise.resolve('studioDeployWorkflow'),
createdAt: Promise.resolve(new Date('2026-09-12T00:00:00.000Z')),
startedAt: Promise.resolve(new Date('2026-09-12T00:00:00.000Z')),
completedAt: Promise.resolve(new Date('2026-09-12T00:00:01.000Z')),
returnValue: overrides.returnValue ?? Promise.resolve({ kind: 'job' }),
};
}

describe('GET /api/workflows/studio-deploy/:runId', () => {
beforeEach(() => {
getRun.mockReset();
});

it('surfaces the failed-run cause instead of a generic unread return value', async () => {
const cause = new Error('Deploy job job_1 still complete');
const failed = new Error('Workflow run failed');
Object.assign(failed, { cause });
const rejected = Promise.reject(failed);
rejected.catch(() => undefined);
getRun.mockReturnValue(
runHandle({
status: 'failed',
returnValue: rejected,
}),
);

const { GET } = await import('../route');
const res = await GET(new Request('https://uvai.io/api/workflows/studio-deploy/wrun_1'), {
params: Promise.resolve({ runId: 'wrun_1' }),
});
const json = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(json.runStatus).toBe('failed');
expect(json.error).toBe('Deploy job job_1 still complete');
expect(json.error).not.toBe('Failed to read workflow return value');
});

it('returns a completed live result when returnValue is readable', async () => {
getRun.mockReturnValue(
runHandle({
status: 'completed',
returnValue: Promise.resolve({
kind: 'live',
live_url: 'https://xy.vercel.app',
jobId: 'job_1',
}),
}),
);

const { GET } = await import('../route');
const res = await GET(new Request('https://uvai.io/api/workflows/studio-deploy/wrun_2'), {
params: Promise.resolve({ runId: 'wrun_2' }),
});
const json = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(json.error).toBeUndefined();
expect(json.result).toEqual({
kind: 'live',
live_url: 'https://xy.vercel.app',
jobId: 'job_1',
});
});
});
70 changes: 38 additions & 32 deletions apps/web/src/app/api/workflows/studio-deploy/[runId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getRun } from 'workflow/api';
import { workflowReturnErrorMessage } from '@/lib/studio-workflow';
import { withWorldVercelFetch } from '@/lib/world-vercel-fetch';
import type { StudioDeployResult } from '@/workflows/studio-deploy';

export const runtime = 'nodejs';
Expand All @@ -15,43 +17,47 @@ export async function GET(
}

try {
const run = getRun<StudioDeployResult>(runId);
const exists = await run.exists;
if (!exists) {
return NextResponse.json(
{ ok: false, runId, error: 'Workflow run not found' },
{ status: 404 },
);
}
const payload = await withWorldVercelFetch(async () => {
const run = getRun<StudioDeployResult>(runId);
const exists = await run.exists;
if (!exists) {
return {
status: 404 as const,
body: { ok: false, runId, error: 'Workflow run not found' },
};
}

const [runStatus, workflowName, createdAt, startedAt, completedAt] =
await Promise.all([
run.status,
run.workflowName.catch(() => undefined),
run.createdAt.then((d) => d.toISOString()).catch(() => undefined),
run.startedAt.then((d) => d?.toISOString()).catch(() => undefined),
run.completedAt.then((d) => d?.toISOString()).catch(() => undefined),
]);
const [runStatus, workflowName, createdAt, startedAt, completedAt] =
await Promise.all([
run.status,
run.workflowName.catch(() => undefined),
run.createdAt.then((d) => d.toISOString()).catch(() => undefined),
run.startedAt.then((d) => d?.toISOString()).catch(() => undefined),
run.completedAt.then((d) => d?.toISOString()).catch(() => undefined),
]);

const payload: Record<string, unknown> = {
ok: true,
runId,
runStatus,
workflowName,
createdAt,
startedAt,
completedAt,
};
const body: Record<string, unknown> = {
ok: true,
runId,
runStatus,
workflowName,
createdAt,
startedAt,
completedAt,
};

if (runStatus === 'completed' || runStatus === 'failed') {
try {
payload.result = await run.returnValue;
} catch {
payload.error = 'Failed to read workflow return value';
if (runStatus === 'completed' || runStatus === 'failed') {
try {
body.result = await run.returnValue;
} catch (err) {
body.error = workflowReturnErrorMessage(err);
}
}
}

return NextResponse.json(payload);
return { status: 200 as const, body };
});

return NextResponse.json(payload.body, { status: payload.status });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/not found|does not exist/i.test(message)) {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/lib/__tests__/gate-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ describe('evaluateStudioDeployTransition', () => {
expect(view.receiptHash).toMatch(/^[a-f0-9]{64}$/);
expect(view.version).toBe(GATE_RECEIPT_VERSION);
});

it('HOLD when the workflow return is missing a live URL — no Deploy completed claim', () => {
const backendReason = 'Backend job finished with no verified live URL';
const result = evaluateStudioDeployTransition({
transitionId: 'wrun_01M2A8RXT1HS8NPW70HV6AA0YV',
runId: 'wrun_01M2A8RXT1HS8NPW70HV6AA0YV',
runStatus: 'completed',
kind: 'job',
backendReason,
authority: { actor: 'anonymous' },
issuedAt: ISSUED_AT,
});
expect(result.decision).toBe('HOLD');
expect(result.reason_code).toBe('GATE_HOLD_MISSING_EVIDENCE');
expect(result.reason.toLowerCase()).not.toMatch(/deploy completed/);
const view = studioGateReceiptView(result, { backendReason });
expect(view.reason).toContain(backendReason);
expect(view.reason).not.toMatch(/Failed to read workflow return value/);
expect(view.receiptId).toBe('er:gate:v1:wrun_01M2A8RXT1HS8NPW70HV6AA0YV');
});
});

describe('studioGateReceiptView', () => {
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/lib/__tests__/pipeline-async-job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,124 @@ describe('pipeline-async-job (WDK C)', () => {
expect(isTerminalJobStatus('running')).toBe(false);
});

it('treats backend JobStatus.complete as terminal (not only completed)', () => {
expect(isTerminalJobStatus('complete')).toBe(true);
expect(isTerminalJobStatus('completed')).toBe(true);
});

it('reads live_url from job.metadata.outputs.deployment without inventing one', async () => {
vi.mocked(getBackendConfig).mockReturnValue({
configured: true,
url: 'https://api.uvai.io',
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
status: 'complete',
metadata: {
outputs: { deployment: { live_url: 'https://ship.example.app' } },
},
},
}),
}),
);
const status = await fetchAsyncVideoJob('job_complete');
expect(status.jobStatus).toBe('complete');
expect(isTerminalJobStatus(status.jobStatus)).toBe(true);
expect(status.live_url).toBe('https://ship.example.app');
});

it('surfaces backend job.error as the status message', async () => {
vi.mocked(getBackendConfig).mockReturnValue({
configured: true,
url: 'https://api.uvai.io',
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: { status: 'failed', error: 'Transcript-action workflow failed' },
}),
}),
);
const status = await fetchAsyncVideoJob('job_err');
expect(status.jobStatus).toBe('failed');
expect(status.message).toBe('Transcript-action workflow failed');
});

it('passes through a video-to-software live_url from kickoff', async () => {
vi.mocked(checkBackendHealth).mockResolvedValue({
configured: true,
available: true,
host: 'api.uvai.io',
});
vi.mocked(getBackendConfig).mockReturnValue({
configured: true,
url: 'https://api.uvai.io',
});
const fetchMock = vi.fn().mockImplementation(async (input: unknown) => {
const href = String(input);
if (href.includes('/video-to-software')) {
return {
ok: true,
status: 200,
json: async () => ({ live_url: 'https://xy.vercel.app' }),
};
}
throw new Error(`unexpected fetch ${href}`);
});
vi.stubGlobal('fetch', fetchMock);
const kicked = await kickoffAsyncVideoJob(
'https://www.youtube.com/watch?v=auJzb1D-fag',
);
expect(kicked.kind).toBe('live');
expect(kicked.live_url).toBe('https://xy.vercel.app');
expect(fetchMock).toHaveBeenCalledWith(
'https://api.uvai.io/api/v1/video-to-software',
expect.objectContaining({ method: 'POST' }),
);
});

it('falls through to videos/process when video-to-software is 401 (not an auth cut)', async () => {
vi.mocked(checkBackendHealth).mockResolvedValue({
configured: true,
available: true,
host: 'api.uvai.io',
});
vi.mocked(getBackendConfig).mockReturnValue({
configured: true,
url: 'https://api.uvai.io',
});
const fetchMock = vi.fn().mockImplementation(async (input: unknown) => {
const href = String(input);
if (href.includes('/video-to-software')) {
return {
ok: false,
status: 401,
json: async () => ({ error: 'Authentication required' }),
};
}
return {
ok: true,
status: 202,
json: async () => ({ data: { job_id: 'job_after_401' } }),
};
});
vi.stubGlobal('fetch', fetchMock);
const kicked = await kickoffAsyncVideoJob(
'https://www.youtube.com/watch?v=auJzb1D-fag',
);
expect(kicked.kind).toBe('job');
expect(kicked.jobId).toBe('job_after_401');
expect(kicked.message ?? '').not.toMatch(/Authentication required/);
});

it('treats a backend HTTP error as failed, not a config handoff', async () => {
vi.mocked(checkBackendHealth).mockResolvedValue({
configured: true,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/__tests__/studio-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,21 @@ describe('studio-deploy (F5)', () => {
expect(polled.live_url).toBe('https://example.vercel.app');
expect(polled.jobStatus).toBe('completed');
});

it('pollStudioJob treats backend complete as terminal', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: { status: 'complete', error: 'no live url on transcript job' },
}),
}),
);
const polled = await pollStudioJob('job_complete', { attempts: 3, delayMs: 0 });
expect(polled.ok).toBe(true);
expect(polled.jobStatus).toBe('complete');
expect(fetch).toHaveBeenCalledTimes(1);
});
});
20 changes: 20 additions & 0 deletions apps/web/src/lib/__tests__/studio-pipeline-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,26 @@ describe('studio-pipeline-status', () => {
});

it('does not claim Deploy completed without a verified live receipt', () => {
expect(
studioDeployOutcomeMessage({
runStatus: 'failed',
error: 'Deploy job job_1 still complete',
}),
).toBe('Deploy job job_1 still complete');
expect(
studioDeployOutcomeMessage({
runStatus: 'completed',
kind: 'job',
message: 'Backend job finished with no verified live URL',
}),
).toBe('Backend job finished with no verified live URL');
expect(
studioDeployOutcomeMessage({
runStatus: 'completed',
kind: 'job',
message: 'Backend job finished with no verified live URL',
}),
).not.toMatch(/Deploy completed/i);
const completedNoUrl = studioDeployOutcomeMessage({ runStatus: 'completed' });
expect(completedNoUrl.toLowerCase()).not.toMatch(/deploy completed/);
expect(completedNoUrl.toLowerCase()).not.toMatch(/\bsuccess(?:ful|fully)?\b/);
Expand Down
Loading
Loading