open-design/apps/daemon/tests/deploy-routes.test.ts
kami 0f586c410d
Fix Cloudflare Pages custom domain lookup (#958)
* Support Cloudflare Pages custom domains without hiding pages.dev fallback

Keep the default Pages preview as the first public link while optional owned-zone binding provisions DNS and Pages custom-domain state in parallel.

Constraint: Cloudflare deploys must use the existing direct-upload API path with no Wrangler dependency.

Constraint: pages.dev must stay visible even while custom-domain verification is pending.

Rejected: Vercel custom-domain support | outside requested Cloudflare-only scope.

Rejected: overwriting arbitrary CNAME records | risks taking over user-managed DNS.

Confidence: high

Scope-risk: moderate

Directive: Do not expose providerMetadata through public deploy contracts; keep custom-domain DNS ownership checks conservative.

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts

Tested: pnpm --filter @open-design/contracts build && pnpm --filter @open-design/contracts typecheck && pnpm --filter @open-design/contracts test

Tested: pnpm --filter @open-design/web typecheck && pnpm --filter @open-design/web test -- providers/registry.test.ts components/FileViewer.test.tsx i18n/locales.test.ts

Tested: pnpm i18n:check && pnpm guard && pnpm typecheck

Tested: pnpm --filter @open-design/daemon build && pnpm --filter @open-design/web build && git diff --check

Not-tested: real Cloudflare account/token/domain smoke test

* Preserve Cloudflare fallback correctness under large accounts and races

Constraint: Cloudflare Pages keeps pages.dev as the primary usable fallback while custom domains remain optional typed metadata.
Rejected: Treating custom-domain DNS or binding failure as a top-level deployment failure | pages.dev can still be ready and usable.
Confidence: high
Scope-risk: moderate
Directive: Keep custom-domain finality tied to Cloudflare Pages API active status plus URL reachability; do not expose providerMetadata.
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/web test -- components/FileViewer.test.tsx i18n/locales.test.ts providers/registry.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm --filter @open-design/web typecheck; pnpm i18n:check; git diff --check; pnpm guard; pnpm typecheck; pnpm --filter @open-design/daemon build; pnpm --filter @open-design/web build
Not-tested: Real Cloudflare token/account/zone smoke test.

* Keep impeccable design notes local

Constraint: .impeccable.md is local assistant/design context and should not be part of the PR diff.
Rejected: Keeping the file tracked while adding it to .gitignore | tracked files are not ignored by Git.
Confidence: high
Scope-risk: narrow
Directive: Keep .impeccable.md untracked and ignored; do not rely on it for required project documentation.
Tested: git check-ignore -v .impeccable.md; git diff --check
Not-tested: Full workspace tests not rerun for ignore-only metadata change.

* Use direct Pages domain lookup for custom bindings

Cloudflare rejects list-style options on Pages custom-domain lookup in some accounts, so the deploy path now reads the selected hostname directly before creating a binding. This keeps pages.dev deployment success intact while avoiding a failed custom-domain branch caused by page/per_page query parameters.

Constraint: Cloudflare Pages custom-domain lookup must not send unsupported page/per_page list options

Rejected: Continue paginating /domains | Cloudflare returns invalid list options before the binding can be created

Confidence: high

Scope-risk: narrow

Directive: Keep pages.dev as the primary deployment URL and treat custom-domain setup as a recoverable secondary branch

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm guard; pnpm typecheck; git diff --check; pnpm --filter @open-design/daemon build

Not-tested: Live Cloudflare deployment was not retriggered from the browser
2026-05-08 21:20:48 +08:00

771 lines
30 KiB
TypeScript

import type http from 'node:http';
import { 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 Open Design 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>');
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');
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 });
}
});
});