Files
PerishFireandGitHub 7bad1a45f2 feat(telemetry): classify publish and deploy failures additively (#8402)
Publish-public and deploy failures were only visible as one opaque bucket
each (`publish_failed`, generic HTTP_400 / Error), so neither PostHog nor the
automatic diagnostics bundles could tell why they failed.

Purely additive — no existing status, error code, event value, trigger or
copy changes:

- daemon: failed publish/unpublish/deploy responses gain an optional
  closed-token `failure` { stage, reason, upstreamStatus?, upstreamCode? }.
  Vela CLI failures are classified from the CLI's stable stderr contract
  (vela-cli 0.0.33–0.1.3); deploy failures separate provider rejection,
  unreachable provider, rejected Vercel token, local file and internal
  errors. Each failure also writes one structured, text-free log line.
- web: artifact_publish_result / artifact_deploy_result gain optional
  failed_stage, failure_reason, upstream_status, upstream_error_code, and
  (publish) daemon_error_code. `error_code` keeps its values.
- correlation: each attempt's analytics request id is sent as
  x-od-request-id, logged by the daemon, and reported as request_id.
2026-09-23 12:19:20 +00:00

1592 lines
67 KiB
TypeScript

import type http from 'node:http';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePagesProjectNameForProject,
deployConfigPath,
VERCEL_PROVIDER_ID,
SAVED_CLOUDFLARE_TOKEN_MASK,
} from '../src/deploy.js';
import { ensureProject } from '../src/projects.js';
import { startServer } from '../src/server.js';
describe('deploy provider routes', () => {
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('dispatches deploy config reads and writes by providerId', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-config-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
expect(await saveResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_123',
projectName: '',
});
const getResp = await fetch(
`${baseUrl}/api/deploy/config?providerId=${CLOUDFLARE_PAGES_PROVIDER_ID}`,
);
expect(getResp.status).toBe(200);
expect(await getResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_123',
projectName: '',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: '',
});
const maskedResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
}),
});
expect(maskedResp.status).toBe(200);
expect(await maskedResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
projectName: '',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_456',
projectName: '',
});
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('lists Cloudflare Pages zones for saved account credentials', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-zones-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
},
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
expect(url).toContain('/zones?');
expect(url).toContain('account.id=account_123');
return new Response(JSON.stringify({
success: true,
result: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
try {
const zonesResp = await fetch(`${baseUrl}/api/deploy/cloudflare-pages/zones`);
expect(zonesResp.status).toBe(200);
expect(await zonesResp.json()).toEqual({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
},
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('dispatches deploy preflight by providerId', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const projectId = `deploy-route-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><meta name="viewport" content="width=device-width"><h1>Hello</h1>',
);
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy/preflight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
}),
});
expect(resp.status).toBe(200);
expect(await resp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
entry: 'index.html',
totalFiles: 1,
});
});
it('derives Cloudflare Pages project names from the OpenDesign project', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-auto-project-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = 'cf-route-123456';
const expectedPagesProject = 'od-ai-cf-route-123';
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'AI 生图网站',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const createFileResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'index.html',
content: '<!doctype html><h1>Hello</h1>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
}),
});
expect(createFileResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.endsWith(`/pages/projects/${expectedPagesProject}`) && method === 'GET') {
return new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
expect(body).toMatchObject({
name: expectedPagesProject,
production_branch: 'main',
});
return new Response(JSON.stringify({ success: true, result: { name: body.name } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/upload-token`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}')) as { hashes?: string[] };
expect(Array.isArray(body.hashes)).toBe(true);
expect(body.hashes?.length).toBeGreaterThan(0);
return new Response(JSON.stringify({ success: true, result: body.hashes }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upload') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '[]')) as Array<{
key?: string;
value?: string;
metadata?: { contentType?: string };
base64?: boolean;
}>;
expect(body).toHaveLength(1);
expect(body[0]?.base64).toBe(true);
expect(body[0]?.metadata?.contentType).toMatch(/^text\/html/);
expect(body[0]?.key).toMatch(/^[a-f0-9]{32}$/);
expect(body[0]?.value).toEqual(expect.any(String));
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}')) as { hashes?: string[] };
expect(Array.isArray(body.hashes)).toBe(true);
expect(body.hashes?.length).toBeGreaterThan(0);
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/deployments`) && method === 'POST') {
const form = init?.body as FormData;
const manifest = JSON.parse(String(form.get('manifest') ?? '{}')) as Record<string, string>;
expect(Object.keys(manifest)).toContain('/index.html');
expect(form.get('branch')).toBe('main');
expect(form.get('pages_build_output_dir')).toBeNull();
return new Response(JSON.stringify({
success: true,
result: { id: 'cf_dep_123', url: `https://d34527d9.${expectedPagesProject}.pages.dev` },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === `https://${expectedPagesProject}.pages.dev` && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { id: string };
expect(deployment).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'cf_dep_123',
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
cloudflarePages: {
projectName: expectedPagesProject,
pagesDev: {
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
},
},
});
expect(deployment).not.toHaveProperty('providerMetadata');
const renameResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Renamed project after deploy' }),
});
expect(renameResp.status).toBe(200);
const checkResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(checkResp.status).toBe(200);
expect(await checkResp.json()).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('rejects invalid Cloudflare custom-domain selection before Pages deploy', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-invalid-domain-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-invalid-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Invalid domain test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
throw new Error(`No external fetch expected before invalid-prefix rejection: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'bad.prefix',
},
}),
});
expect(deployResp.status).toBe(400);
expect(await deployResp.text()).toMatch(/valid subdomain prefix/i);
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('refreshes Cloudflare Pages custom-domain API status during check-link', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-domain-check-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-domain-check-${Date.now()}`;
const expectedPagesProject = cloudflarePagesProjectNameForProject(projectId, 'Domain check test');
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Domain check test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
let domainListCount = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.endsWith(`/pages/projects/${expectedPagesProject}`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: expectedPagesProject } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/upload-token`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/deployments`) && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'cf_dep_domain_check', url: `https://d34527d9.${expectedPagesProject}.pages.dev` },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === `https://${expectedPagesProject}.pages.dev` && method === 'HEAD') {
return new Response('', { status: 200 });
}
if (url.endsWith('/zones/zone-1') && method === 'GET') {
return new Response(JSON.stringify({
success: true,
result: { id: 'zone-1', name: 'example.com', status: 'active', type: 'full' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/zones/zone-1/dns_records?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/zones/zone-1/dns_records') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
return new Response(JSON.stringify({ success: true, result: { id: 'dns-1', ...body } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/domains/demo.example.com`) && method === 'GET') {
domainListCount += 1;
if (domainListCount === 1) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
const result = {
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
};
return new Response(JSON.stringify({ success: true, result }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/domains`) && method === 'POST') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ name: 'demo.example.com' });
return new Response(JSON.stringify({
success: true,
result: { name: 'demo.example.com', status: 'pending' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo.example.com' && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { id: string };
expect(deployment).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: `https://${expectedPagesProject}.pages.dev`,
status: 'link-delayed',
cloudflarePages: {
pagesDev: { url: `https://${expectedPagesProject}.pages.dev`, status: 'ready' },
customDomain: {
hostname: 'demo.example.com',
status: 'pending',
domainStatus: 'pending',
},
},
});
expect(deployment).not.toHaveProperty('providerMetadata');
const pendingResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(pendingResp.status).toBe(200);
const pending = await pendingResp.json();
expect(pending).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'link-delayed',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'pending',
domainStatus: 'pending',
pagesDomainStatus: 'pending',
},
},
});
expect(pending).not.toHaveProperty('providerMetadata');
const readyResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(readyResp.status).toBe(200);
const ready = await readyResp.json();
expect(ready).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
domainStatus: 'active',
pagesDomainStatus: 'active',
validationData: { txt_name: '_cf-custom-hostname.demo.example.com' },
verificationData: { cname: `${expectedPagesProject}.pages.dev` },
},
},
});
expect(ready).not.toHaveProperty('providerMetadata');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('keeps Vercel deploy payload free of Cloudflare custom-domain fields', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-vercel-payload-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `vercel-payload-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
await writeFile(path.join(dir, 'index-v1.html'), '<!doctype html><h1>V1</h1>');
await mkdir(path.join(dir, 'screens'), { recursive: true });
await writeFile(path.join(dir, 'screens', 'k1-waiting.html'), '<!doctype html><h1>K1</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Vercel payload test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: VERCEL_PROVIDER_ID,
token: 'vercel-token-secret',
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.includes('/v13/deployments') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
expect(body).not.toHaveProperty('cloudflarePages');
expect(JSON.stringify(body)).not.toContain('example.com');
expect(body.files.map((item: { file: string }) => item.file).sort()).toEqual([
'index-v1.html',
'index.html',
'screens/k1-waiting.html',
]);
return new Response(JSON.stringify({
id: 'vercel-dep-1',
readyState: 'READY',
url: 'vercel.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/v13/deployments/vercel-dep-1') && method === 'GET') {
return new Response(JSON.stringify({
id: 'vercel-dep-1',
readyState: 'READY',
url: 'vercel.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://vercel.example' && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: VERCEL_PROVIDER_ID,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
}),
});
expect(deployResp.status).toBe(200);
expect(await deployResp.json()).toMatchObject({
providerId: VERCEL_PROVIDER_ID,
url: 'https://vercel.example',
status: 'ready',
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// --- target threading tests (issue #4483) ---
function makeCfPagesMockForRouteTarget(options: {
previewDeployUrl: string;
captureFormData: { branch: string | undefined };
expectedPagesProject: string;
}) {
const { previewDeployUrl, captureFormData, expectedPagesProject } = options;
const realFetch = globalThis.fetch;
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.endsWith(`/pages/projects/${expectedPagesProject}`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: expectedPagesProject } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/upload-token`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/deployments`) && method === 'POST') {
const form = init?.body as FormData;
captureFormData.branch = form?.get('branch') as string | undefined ?? undefined;
return new Response(JSON.stringify({
success: true,
result: { id: 'cf_dep_target_test', url: previewDeployUrl },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
}
it('threads target=preview from POST body into the deployment record', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-target-preview-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-target-preview-${Date.now()}`;
const expectedPagesProject = cloudflarePagesProjectNameForProject(projectId, 'Target preview test');
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Target preview test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const captureFormData: { branch: string | undefined } = { branch: undefined };
const fetchMock = makeCfPagesMockForRouteTarget({
previewDeployUrl: `https://abc123.${expectedPagesProject}.pages.dev`,
captureFormData,
expectedPagesProject,
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
target: 'preview',
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { target: string };
// Route must persist the actual requested target, not always 'preview'
expect(deployment.target).toBe('preview');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('threads target=production from POST body into the deployment record', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-target-prod-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-target-prod-${Date.now()}`;
const expectedPagesProject = cloudflarePagesProjectNameForProject(projectId, 'Target prod test');
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Target prod test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const captureFormData: { branch: string | undefined } = { branch: undefined };
const fetchMock = makeCfPagesMockForRouteTarget({
previewDeployUrl: `https://abc123.${expectedPagesProject}.pages.dev`,
captureFormData,
expectedPagesProject,
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
target: 'production',
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { target: string };
// An explicit target='production' in the body must be reflected in
// the persisted record; the current code hardcodes 'preview' and will fail.
expect(deployment.target).toBe('production');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// --- target validation tests (P1 finding on PR #4576) ---
/**
* Helper: minimal project + CF config setup, no fetch mock needed.
* Returns the projectId so callers can POST to /deploy.
*/
async function setupProjectAndCfConfig(
stateRoot: string,
projectIdPrefix: string,
projectName: string,
): Promise<string> {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const projectId = `${projectIdPrefix}-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId, name: projectName, skillId: null, designSystemId: null }),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
return projectId;
}
it('rejects a misspelled target value with HTTP 400 and does not invoke Cloudflare deploy', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-invalid-target-typo-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndCfConfig(stateRoot, 'cf-invalid-typo', 'Invalid target typo test');
// Stub fetch so any accidental external call fails loudly — the route
// must return 400 BEFORE attempting a Cloudflare API call.
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
throw new Error(`No Cloudflare deploy call expected for an invalid target: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
target: 'preveiw', // deliberate typo — not 'preview' or 'production'
}),
});
// Must reject with 400, not silently coerce to 'production'
expect(deployResp.status).toBe(400);
// Cloudflare deploy endpoint must never have been called
const cfDeployCalls = fetchMock.mock.calls.filter((args) => {
const u = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : String(args[0]);
return !u.startsWith(baseUrl);
});
expect(cfDeployCalls).toHaveLength(0);
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('rejects an empty-string target value with HTTP 400 and does not invoke Cloudflare deploy', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-invalid-target-empty-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndCfConfig(stateRoot, 'cf-invalid-empty', 'Invalid target empty test');
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
throw new Error(`No Cloudflare deploy call expected for an empty-string target: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
target: '', // supplied but empty — not a valid value, not the same as omitted
}),
});
// An explicitly supplied empty string is an invalid target; must be 400
expect(deployResp.status).toBe(400);
const cfDeployCalls = fetchMock.mock.calls.filter((args) => {
const u = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : String(args[0]);
return !u.startsWith(baseUrl);
});
expect(cfDeployCalls).toHaveLength(0);
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// Regression guards — these must PASS both before and after the fix to pin
// the correct contract for the two valid explicit values and the omitted case.
it('defaults to target=production and records production in the deployment when no target is sent', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-target-default-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-target-default-${Date.now()}`;
const expectedPagesProject = cloudflarePagesProjectNameForProject(projectId, 'Target default test');
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Target default test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const captureFormData: { branch: string | undefined } = { branch: undefined };
const fetchMock = makeCfPagesMockForRouteTarget({
previewDeployUrl: `https://abc123.${expectedPagesProject}.pages.dev`,
captureFormData,
expectedPagesProject,
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
// no target field — should default to production
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { target: string };
// When target is not supplied the deployment record must say 'production',
// not 'preview' (which is the current hardcoded behaviour)
expect(deployment.target).toBe('production');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// --- Vercel production-target rejection tests (P2 review finding on PR #4576) ---
//
// Vercel production-target deploys are out of scope for this PR (which only
// adds target support for Cloudflare Pages). The route must reject
// providerId === VERCEL_PROVIDER_ID + resolved target === 'production' with
// HTTP 400 / BAD_REQUEST *before* attempting any deploy call, instead of
// silently deploying as preview.
/**
* Helper: minimal project + Vercel config setup, no fetch mock needed.
* Returns the projectId so callers can POST to /deploy.
*/
async function setupProjectAndVercelConfig(
projectIdPrefix: string,
projectName: string,
): Promise<string> {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const projectId = `${projectIdPrefix}-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId, name: projectName, skillId: null, designSystemId: null }),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: VERCEL_PROVIDER_ID,
token: 'vercel-token-secret',
}),
});
expect(saveResp.status).toBe(200);
return projectId;
}
function makeVercelDeployMock() {
const realFetch = globalThis.fetch;
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.includes('/v13/deployments') && method === 'POST') {
return new Response(JSON.stringify({
id: 'vercel-dep-still-works',
readyState: 'READY',
url: 'vercel-still-works.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/v13/deployments/vercel-dep-still-works') && method === 'GET') {
return new Response(JSON.stringify({
id: 'vercel-dep-still-works',
readyState: 'READY',
url: 'vercel-still-works.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://vercel-still-works.example' && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
}
// Every deploy failure used to flatten to the envelope's BAD_REQUEST, so a
// missing token, a non-HTML file, an unresolved asset reference and an
// oversized asset were indistinguishable once the client mirrored the code
// into `artifact_deploy_result.error_code` — in production that collapsed
// into one opaque HTTP_400 bucket we could not act on. Distinct causes must
// carry distinct codes.
it('surfaces a specific error code for a non-HTML deploy instead of a generic BAD_REQUEST', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-error-code-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('deploy-error-code', 'Deploy error code test');
await writeFile(path.join(dataDir, 'projects', projectId, 'notes.txt'), 'not html');
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: 'notes.txt', providerId: VERCEL_PROVIDER_ID }),
});
const text = await resp.text();
expect(resp.status, text).toBe(400);
const body = JSON.parse(text) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('NOT_HTML');
expect(body.error?.message).toMatch(/HTML/i);
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// The other half of the same telemetry contract: a failure the provider
// rejected is classified by ITS HTTP status client-side (HTTP_403 /
// HTTP_429 / HTTP_502), and the client only falls back to that status when
// the envelope code is generic. So the provider catch-alls must NOT stamp a
// structured code — doing so would fold auth, quota and upstream faults into
// one bucket, which is coarser than what production already had.
it('leaves a provider-rejected deploy on the generic envelope code so the client keeps status bucketing', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-provider-status-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('provider-status', 'Provider status test');
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.includes('/v13/deployments')) {
return new Response(JSON.stringify({ error: { code: 'too_many_requests', message: 'Too many requests.' } }), {
status: 429,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const resp = await realFetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: 'index.html', providerId: VERCEL_PROVIDER_ID }),
});
const text = await resp.text();
expect(resp.status, text).toBe(429);
const body = JSON.parse(text) as { error?: { code?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// Additive failure detail: status and envelope code stay exactly as above,
// and `error.failure` adds what that generic code cannot say.
it('adds a closed-token failure detail without changing status or envelope code', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-failure-detail-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('failure-detail', 'Failure detail test');
const realFetch = globalThis.fetch;
let providerResponse: () => Promise<Response> = async () => { throw new Error('unset'); };
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.includes('/v13/deployments')) return providerResponse();
throw new Error(`Unexpected fetch: ${url}`);
}));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const deploy = async () => {
const resp = await realFetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-od-request-id': 'req-deploy-0001' },
body: JSON.stringify({ fileName: 'index.html', providerId: VERCEL_PROVIDER_ID }),
});
return { status: resp.status, body: await resp.json() as { error?: Record<string, unknown> } };
};
try {
providerResponse = async () => new Response(
JSON.stringify({ error: { code: 'too_many_requests', message: 'Too many requests.' } }),
{ status: 429, headers: { 'content-type': 'application/json' } },
);
const rejected = await deploy();
expect(rejected.status).toBe(429);
expect(rejected.body.error?.code).toBe('BAD_REQUEST');
expect(rejected.body.error?.failure).toEqual({
stage: 'provider', reason: 'provider_rejected', upstreamStatus: 429, upstreamCode: 'too_many_requests',
});
providerResponse = async () => { throw new TypeError('fetch failed'); };
const unreachable = await deploy();
expect(unreachable.status).toBe(400);
expect(unreachable.body.error?.code).toBe('BAD_REQUEST');
expect(unreachable.body.error?.failure).toEqual({ stage: 'provider', reason: 'provider_unreachable' });
providerResponse = async () => new Response(
JSON.stringify({ error: { code: 'forbidden', message: 'Not authorized', invalidToken: true } }),
{ status: 403, headers: { 'content-type': 'application/json' } },
);
const tokenRejected = await deploy();
expect(tokenRejected.status).toBe(403);
expect(tokenRejected.body.error?.code).toBe('PROVIDER_FORBIDDEN');
expect(tokenRejected.body.error?.failure).toEqual({
stage: 'provider', reason: 'provider_token_invalid', upstreamStatus: 403, upstreamCode: 'forbidden',
});
const lines = warn.mock.calls
.filter(([label]) => label === '[od] deploy failure')
.map(([, json]) => JSON.parse(String(json)) as Record<string, unknown>);
expect(lines.map((line) => line.reason)).toEqual([
'provider_rejected', 'provider_unreachable', 'provider_token_invalid',
]);
expect(lines.every((line) => line.requestId === 'req-deploy-0001' && line.providerId === VERCEL_PROVIDER_ID)).toBe(true);
} finally {
warn.mockRestore();
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// A provider failure whose CAUSE is known — not merely its status — still
// earns a specific code.
it('reports PROVIDER_FORBIDDEN when the provider names a permission failure', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-provider-forbidden-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('provider-forbidden', 'Provider forbidden test');
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.includes('/v13/deployments')) {
return new Response(JSON.stringify({ error: { code: 'forbidden', message: 'Not authorized.' } }), {
status: 403,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const resp = await realFetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: 'index.html', providerId: VERCEL_PROVIDER_ID }),
});
const text = await resp.text();
expect(resp.status, text).toBe(403);
const body = JSON.parse(text) as { error?: { code?: string } };
expect(body.error?.code).toBe('PROVIDER_FORBIDDEN');
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
// The config-save route is the only place CF_TOKEN_REQUIRED can surface, so
// it needs the same passthrough as the deploy route — otherwise the code is
// dead on arrival.
it('surfaces CF_TOKEN_REQUIRED when saving a Cloudflare Pages config without a token', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-cf-token-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const resp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providerId: CLOUDFLARE_PAGES_PROVIDER_ID, accountId: 'acct-1' }),
});
const text = await resp.text();
expect(resp.status, text).toBe(400);
const body = JSON.parse(text) as { error?: { code?: string } };
expect(body.error?.code).toBe('CF_TOKEN_REQUIRED');
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('rejects vercel-self target=production with 400 BAD_REQUEST before attempting a deploy', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-vercel-prod-reject-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('vercel-prod-reject', 'Vercel production reject test');
// Use a fetch mock that WOULD happily complete a Vercel deploy if the
// route called it — this is the same mock the "still works" companion
// tests use for a legitimate preview deploy. If the route's guard is
// missing (today's bug), the deploy proceeds and this mock lets it
// succeed with 200, which is exactly the silent-preview-deploy bug
// this test must catch. A correct fix never reaches this mock at all.
const fetchMock = makeVercelDeployMock();
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: VERCEL_PROVIDER_ID,
target: 'production',
}),
});
const bodyText = await deployResp.text();
// Must reject with 400, not silently deploy as preview (the bug: this
// currently returns 200 with a deployment that is actually 'preview').
expect(deployResp.status, bodyText).toBe(400);
const body = JSON.parse(bodyText) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/production|target/i);
// The Vercel deploy endpoint must never have been called — the route
// must reject before attempting any deploy call.
const vercelDeployCalls = fetchMock.mock.calls.filter((args) => {
const u = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : String(args[0]);
return !u.startsWith(baseUrl);
});
expect(vercelDeployCalls).toHaveLength(0);
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('still deploys vercel-self successfully when target=preview is explicit (no regression)', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-vercel-preview-ok-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('vercel-preview-ok', 'Vercel preview still works test');
const fetchMock = makeVercelDeployMock();
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: VERCEL_PROVIDER_ID,
target: 'preview',
}),
});
const bodyText = await deployResp.text();
expect(deployResp.status, bodyText).toBe(200);
const deployment = JSON.parse(bodyText) as { providerId: string; url: string; status: string };
expect(deployment).toMatchObject({
providerId: VERCEL_PROVIDER_ID,
url: 'https://vercel-still-works.example',
status: 'ready',
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('still deploys vercel-self successfully when target is omitted (no regression)', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-vercel-omitted-ok-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const projectId = await setupProjectAndVercelConfig('vercel-omitted-ok', 'Vercel omitted target still works test');
const fetchMock = makeVercelDeployMock();
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: VERCEL_PROVIDER_ID,
// no target field — must keep working exactly as before the fix.
}),
});
const bodyText = await deployResp.text();
expect(deployResp.status, bodyText).toBe(200);
const deployment = JSON.parse(bodyText) as { providerId: string; url: string; status: string };
expect(deployment).toMatchObject({
providerId: VERCEL_PROVIDER_ID,
url: 'https://vercel-still-works.example',
status: 'ready',
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
});