mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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.
1592 lines
67 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|