open-design/apps/daemon/tests/deploy.test.ts
kami 2eae7da24b
feat: support Cloudflare Pages custom domains (#851)
* 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.
2026-05-08 11:11:22 +08:00

2298 lines
83 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
analyzeDeployPlan,
buildDeployFilePlan,
buildDeployFileSet,
checkDeploymentUrl,
chunkCloudflarePagesAssetUploads,
CLOUDFLARE_PAGES_ASSET_MAX_BYTES,
CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePagesAssetHash,
cloudflarePagesProjectNameForProject,
DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES,
DEPLOY_PREFLIGHT_LARGE_HTML_BYTES,
deploymentUrlCandidates,
deployToCloudflarePages,
deployConfigPath,
extractCssReferences,
extractHtmlReferences,
extractInlineCssReferences,
injectDeployHookScript,
isVercelProtectedResponse,
listCloudflarePagesZones,
normalizeDeployHookScriptUrl,
prepareDeployPreflight,
publicDeployConfig,
readVercelConfig,
resolveReferencedPath,
rewriteCssReferences,
rewriteEntryHtmlReferences,
SAVED_CLOUDFLARE_TOKEN_MASK,
SAVED_TOKEN_MASK,
VERCEL_PROVIDER_ID,
waitForReachableDeploymentUrl,
writeCloudflarePagesConfig,
writeVercelConfig,
} from '../src/deploy.js';
import { closeDatabase, getDeployment, insertProject, openDatabase, upsertDeployment } from '../src/db.js';
import { ensureProject } from '../src/projects.js';
async function setupProject() {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-test-'));
const projectId = 'p1';
const dir = await ensureProject(path.join(root, 'projects'), projectId);
return { projectsRoot: path.join(root, 'projects'), projectId, dir };
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
closeDatabase();
});
describe('deploy config', () => {
it('stores Vercel credentials in vercel.json and returns only the public mask', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-test-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saved = await writeVercelConfig({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'design-team',
});
expect(path.basename(deployConfigPath())).toBe('vercel.json');
expect(saved).toEqual({
providerId: VERCEL_PROVIDER_ID,
configured: true,
tokenMask: SAVED_TOKEN_MASK,
teamId: 'team_123',
teamSlug: 'design-team',
target: 'preview',
});
expect(JSON.parse(await readFile(deployConfigPath(), 'utf8'))).toEqual({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'design-team',
});
const maskedUpdate = await writeVercelConfig({
token: SAVED_TOKEN_MASK,
teamSlug: 'renamed-team',
});
expect(maskedUpdate.tokenMask).toBe(SAVED_TOKEN_MASK);
expect(await readVercelConfig()).toEqual({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'renamed-team',
});
} 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 public config provider metadata stable', () => {
expect(publicDeployConfig({
token: 'vercel-token-secret',
teamId: '',
teamSlug: '',
})).toEqual({
providerId: VERCEL_PROVIDER_ID,
configured: true,
tokenMask: SAVED_TOKEN_MASK,
teamId: '',
teamSlug: '',
target: 'preview',
});
});
it('stores Cloudflare Pages credentials separately from vercel.json', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-test-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saved = await writeCloudflarePagesConfig({
token: 'cloudflare-token-secret',
accountId: 'account_123',
});
expect(path.basename(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID))).toBe('cloudflare-pages.json');
expect(path.basename(deployConfigPath(VERCEL_PROVIDER_ID))).toBe('vercel.json');
expect(saved).toEqual({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
teamId: '',
teamSlug: '',
accountId: 'account_123',
projectName: '',
target: 'preview',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: '',
});
const maskedUpdate = await writeCloudflarePagesConfig({
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
});
expect(maskedUpdate.tokenMask).toBe(SAVED_CLOUDFLARE_TOKEN_MASK);
expect(maskedUpdate.accountId).toBe('account_456');
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_456',
projectName: '',
});
const withDomainHints = await writeCloudflarePagesConfig({
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
},
});
expect((withDomainHints as any).cloudflarePages).toEqual({
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
});
const withoutDomainPrefix = await writeCloudflarePagesConfig({
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
},
});
expect((withoutDomainPrefix as any).cloudflarePages).toEqual({
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toMatchObject({
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
},
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8')).cloudflarePages).not.toHaveProperty(
'lastDomainPrefix',
);
} 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('requires Cloudflare Pages token and account id while deriving project names automatically', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-required-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
await expect(writeCloudflarePagesConfig({
token: 'cloudflare-token-secret',
})).rejects.toThrow(/account ID is required/i);
await expect(writeCloudflarePagesConfig({
accountId: 'account_123',
})).rejects.toThrow(/API token is required/i);
expect(cloudflarePagesProjectNameForProject('project-123', 'AI 生图网站')).toBe(
'od-ai-project-123',
);
expect(cloudflarePagesProjectNameForProject('12345678', '中文项目')).toBe(
'od-project-12345678',
);
} 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 });
}
});
});
describe('deploy file set', () => {
it('deploys a single html file as index.html', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'page.html'), '<!doctype html><h1>Hello</h1>');
const files = await buildDeployFileSet(projectsRoot, projectId, 'page.html');
expect(files.map((f) => f.file)).toEqual(['index.html']);
});
it('injects a closeable deploy hook script from cdn when configured', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'page.html'), '<!doctype html><body><h1>Hello</h1></body>');
const files = await buildDeployFileSet(projectsRoot, projectId, 'page.html', {
hookScriptUrl: 'https://cdn.example.com/open-design-hook.js',
});
const html = files.find((f) => f.file === 'index.html')?.data.toString('utf8') ?? '';
expect(html).toContain(
'<script src="https://cdn.example.com/open-design-hook.js" defer data-open-design-deploy-hook="true" data-closeable="true"></script></body>',
);
});
it('includes referenced html and css assets', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'assets'));
await writeFile(
path.join(dir, 'index.html'),
'<link href="style.css" rel="stylesheet"><script src="app.js"></script><img src="assets/logo.png">',
);
await writeFile(path.join(dir, 'style.css'), '@import "./theme.css"; body{background:url("assets/bg.png")}');
await writeFile(path.join(dir, 'theme.css'), '@font-face{src:url("font.woff2")}');
await writeFile(path.join(dir, 'app.js'), 'console.log("ok")');
await writeFile(path.join(dir, 'font.woff2'), 'font');
await writeFile(path.join(dir, 'assets', 'logo.png'), 'logo');
await writeFile(path.join(dir, 'assets', 'bg.png'), 'bg');
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
expect(files.map((f) => f.file).sort()).toEqual([
'app.js',
'assets/bg.png',
'assets/logo.png',
'font.woff2',
'index.html',
'style.css',
'theme.css',
]);
});
it('rewrites subdirectory html references to preserved project paths', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
await writeFile(
path.join(dir, 'sub', 'page.html'),
'<!doctype html><img src="assets/logo.png?cache=1#mark"><img src="/assets/root.png"><img srcset="assets/small.png 1x, assets/large.png 2x">',
);
await writeFile(path.join(dir, 'sub', 'assets', 'logo.png'), 'logo');
await writeFile(path.join(dir, 'sub', 'assets', 'small.png'), 'small');
await writeFile(path.join(dir, 'sub', 'assets', 'large.png'), 'large');
await mkdir(path.join(dir, 'assets'));
await writeFile(path.join(dir, 'assets', 'root.png'), 'root');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual([
'assets/root.png',
'index.html',
'sub/assets/large.png',
'sub/assets/logo.png',
'sub/assets/small.png',
]);
expect(index?.data.toString('utf8')).toContain('src="sub/assets/logo.png?cache=1#mark"');
expect(index?.data.toString('utf8')).toContain('src="/assets/root.png"');
expect(index?.data.toString('utf8')).toContain(
'srcset="sub/assets/small.png 1x, sub/assets/large.png 2x"',
);
});
it('keeps css content unchanged while deploying subdirectory css assets', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
await writeFile(path.join(dir, 'sub', 'page.html'), '<link href="style.css" rel="stylesheet">');
await writeFile(path.join(dir, 'sub', 'style.css'), 'body{background:url("assets/bg.png")}');
await writeFile(path.join(dir, 'sub', 'assets', 'bg.png'), 'bg');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
const css = files.find((f) => f.file === 'sub/style.css');
expect(files.map((f) => f.file).sort()).toEqual([
'index.html',
'sub/assets/bg.png',
'sub/style.css',
]);
expect(index?.data.toString('utf8')).toContain('href="sub/style.css"');
expect(css?.data.toString('utf8')).toBe('body{background:url("assets/bg.png")}');
});
it('rejects missing referenced local files', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'index.html'), '<img src="missing.png">');
await expect(buildDeployFileSet(projectsRoot, projectId, 'index.html')).rejects.toMatchObject({
details: { missing: ['missing.png'] },
});
});
it('does not treat navigation hrefs as deploy dependencies', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><a href="/pricing">Pricing</a><a href="contact">Contact</a>',
);
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file)).toEqual(['index.html']);
expect(index?.data.toString('utf8')).toContain('href="/pricing"');
expect(index?.data.toString('utf8')).toContain('href="contact"');
});
it('collects and rewrites unquoted asset attributes', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
await writeFile(
path.join(dir, 'sub', 'page.html'),
'<!doctype html><img src=assets/logo.png><video poster=assets/poster.png></video>',
);
await writeFile(path.join(dir, 'sub', 'assets', 'logo.png'), 'logo');
await writeFile(path.join(dir, 'sub', 'assets', 'poster.png'), 'poster');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual([
'index.html',
'sub/assets/logo.png',
'sub/assets/poster.png',
]);
expect(index?.data.toString('utf8')).toContain('src=sub/assets/logo.png');
expect(index?.data.toString('utf8')).toContain('poster=sub/assets/poster.png');
});
it('ignores arbitrary URI schemes in html references', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<iframe src="about:blank"></iframe><a href="ftp://example.com/file">ftp</a><a href="sms:+15555550123">sms</a>',
);
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
expect(files.map((f) => f.file)).toEqual(['index.html']);
});
it('ignores src-like text inside inline scripts', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><script>const text = \'<img src="missing.png">\';</script>',
);
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
expect(files.map((f) => f.file)).toEqual(['index.html']);
});
it('collects and rewrites unquoted stylesheet links', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub'), { recursive: true });
await writeFile(path.join(dir, 'sub', 'page.html'), '<link href=style.css rel=stylesheet>');
await writeFile(path.join(dir, 'sub', 'style.css'), 'body{color:red}');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/style.css']);
expect(index?.data.toString('utf8')).toContain('href=sub/style.css');
});
it('ignores remote, data, blob, mail, and anchor references', () => {
const refs = extractHtmlReferences(
'<a href="#x"></a><img src="https://x.test/a.png"><img src="data:image/png,abc"><script src="//cdn.test/a.js"></script><a href="mailto:a@test.com"></a>',
)
.map((ref) => resolveReferencedPath(ref, '.'))
.filter(Boolean);
expect(refs).toEqual([]);
});
it('extracts css imports and urls', () => {
expect(extractCssReferences('@import "./theme.css"; body{background:url("img/bg.png")}')).toEqual([
'img/bg.png',
'./theme.css',
]);
});
it('rewrites only local relative entry references', () => {
expect(
rewriteEntryHtmlReferences(
'<a href="#x"></a><img src="https://x.test/a.png"><img src="data:image/png,abc"><script src="//cdn.test/a.js"></script><img src="asset.png">',
'sub',
),
).toContain('src="sub/asset.png"');
});
it('ignores invalid deploy hook script urls', () => {
expect(injectDeployHookScript('<body></body>', 'javascript:alert(1)')).toBe('<body></body>');
expect(normalizeDeployHookScriptUrl('https://cdn.example.com/hook.js')).toBe(
'https://cdn.example.com/hook.js',
);
});
it('extracts url() and @import refs from inline <style> blocks', () => {
const refs = extractInlineCssReferences(
'<!doctype html><style>@import "theme.css";body{background:url("bg.png")}</style>',
);
expect(refs.sort()).toEqual(['bg.png', 'theme.css']);
});
it('extracts url() refs from style="" attributes', () => {
const refs = extractInlineCssReferences(
"<div style=\"background:url('bg.png')\"></div><span style=\"--bg:url(/abs.png)\"></span>",
);
expect(refs.sort()).toEqual(['/abs.png', 'bg.png']);
});
it('skips style-like text inside scripts and comments', () => {
const refs = extractInlineCssReferences(
'<!-- <style>body{background:url("ghost.png")}</style> -->' +
'<script>const css = \'<style>body{background:url("missing.png")}</style>\';</script>',
);
expect(refs).toEqual([]);
});
it('rewrites url() and @import refs in css content relative to baseDir', () => {
expect(
rewriteCssReferences(
'@import "theme.css";body{background:url("bg.png")}',
'sub',
),
).toBe('@import "sub/theme.css";body{background:url("sub/bg.png")}');
});
it('keeps remote, data, and absolute css refs intact when rewriting', () => {
expect(
rewriteCssReferences(
'body{background:url("https://cdn.test/a.png");--data:url(data:image/png,abc);--root:url("/abs.png")}',
'sub',
),
).toBe(
'body{background:url("https://cdn.test/a.png");--data:url(data:image/png,abc);--root:url("/abs.png")}',
);
});
it('bundles assets referenced from inline <style> blocks', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'assets'));
await mkdir(path.join(dir, 'fonts'));
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><style>' +
'@import "theme.css";' +
"body{background:url('assets/bg.png')}" +
'@font-face{font-family:Custom;src:url("fonts/custom.woff2") format("woff2");}' +
'</style>',
);
await writeFile(path.join(dir, 'theme.css'), 'body{color:red}');
await writeFile(path.join(dir, 'assets', 'bg.png'), 'bg');
await writeFile(path.join(dir, 'fonts', 'custom.woff2'), 'font');
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
expect(files.map((f) => f.file).sort()).toEqual([
'assets/bg.png',
'fonts/custom.woff2',
'index.html',
'theme.css',
]);
});
it('bundles assets referenced from style="" attributes', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'assets'));
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><div style="background:url(\'assets/hero.png\')">x</div>',
);
await writeFile(path.join(dir, 'assets', 'hero.png'), 'hero');
const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html');
expect(files.map((f) => f.file).sort()).toEqual(['assets/hero.png', 'index.html']);
});
it('rewrites inline <style> url() refs when entry is in a subdirectory', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
await writeFile(
path.join(dir, 'sub', 'page.html'),
'<!doctype html><style>body{background:url("assets/bg.png")}</style>',
);
await writeFile(path.join(dir, 'sub', 'assets', 'bg.png'), 'bg');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/assets/bg.png']);
expect(index?.data.toString('utf8')).toContain('url("sub/assets/bg.png")');
});
it('rewrites style="" url() refs when entry is in a subdirectory', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub'), { recursive: true });
await writeFile(
path.join(dir, 'sub', 'page.html'),
"<!doctype html><div style=\"background:url('hero.png')\">x</div>",
);
await writeFile(path.join(dir, 'sub', 'hero.png'), 'hero');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/hero.png']);
expect(index?.data.toString('utf8')).toContain("url('sub/hero.png')");
});
it('reports inline <style> assets that are missing on disk', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><style>body{background:url("assets/missing.png")}</style>',
);
await expect(
buildDeployFileSet(projectsRoot, projectId, 'index.html'),
).rejects.toMatchObject({
details: { missing: ['assets/missing.png'] },
});
});
it('extracts and rewrites url() refs from <style> inside <svg>', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
await writeFile(
path.join(dir, 'sub', 'page.html'),
'<!doctype html><svg><style>circle{fill:url("assets/icon.svg")}</style></svg>',
);
await writeFile(path.join(dir, 'sub', 'assets', 'icon.svg'), '<svg/>');
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/assets/icon.svg']);
expect(index?.data.toString('utf8')).toContain('url("sub/assets/icon.svg")');
});
it('does not rewrite <style>-like text inside <script> string literals', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'sub'), { recursive: true });
const html =
'<!doctype html><script>const tpl = \'<style>body{background:url("assets/bg.png")}</style>\';</script>';
await writeFile(path.join(dir, 'sub', 'page.html'), html);
const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html');
const index = files.find((f) => f.file === 'index.html');
// The fake <style> lives inside a JS string literal, so it must not
// be processed as inline CSS: no asset is bundled and the script
// body is preserved byte-for-byte.
expect(files.map((f) => f.file)).toEqual(['index.html']);
expect(index?.data.toString('utf8')).toContain(
"const tpl = '<style>body{background:url(\"assets/bg.png\")}</style>';",
);
});
it('does not rewrite <style>-like text inside HTML comments', () => {
const html =
'<!doctype html><!-- <style>body{background:url("ghost.png")}</style> --><h1>x</h1>';
expect(rewriteEntryHtmlReferences(html, 'sub')).toBe(html);
});
it('runs in linear time on pathological unclosed url(', () => {
const huge = '('.repeat(100_000);
const input = `body{background:url${huge}}`;
const startExtract = Date.now();
const refs = extractCssReferences(input);
expect(Date.now() - startExtract).toBeLessThan(500);
expect(refs).toEqual([]);
const startRewrite = Date.now();
expect(rewriteCssReferences(input, 'sub')).toBe(input);
expect(Date.now() - startRewrite).toBeLessThan(500);
});
});
describe('deploy plan and analyzer', () => {
async function setupProject() {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-plan-test-'));
const projectId = 'p1';
const dir = await ensureProject(path.join(root, 'projects'), projectId);
return { projectsRoot: path.join(root, 'projects'), projectId, dir };
}
it('returns the file set plus missing and invalid lists without throwing', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><meta name="viewport" content="width=device-width"><img src="missing.png">',
);
const plan = await buildDeployFilePlan(projectsRoot, projectId, 'index.html');
expect(plan.entryPath).toBe('index.html');
expect(plan.files.map((f) => f.file)).toEqual(['index.html']);
expect(plan.missing).toEqual(['missing.png']);
expect(plan.invalid).toEqual([]);
});
it('flags missing assets as broken-reference warnings', () => {
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<!doctype html><meta name="viewport" content="width=device-width">',
files: [
{ file: 'index.html', data: Buffer.from('<!doctype html>'), contentType: 'text/html', sourcePath: 'index.html' },
],
missing: ['logo.png'],
invalid: [],
});
expect(warnings).toContainEqual(
expect.objectContaining({ code: 'broken-reference', path: 'logo.png' }),
);
});
it('flags invalid references separately from missing ones', () => {
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<!doctype html><meta name="viewport" content="width=device-width">',
files: [],
missing: [],
invalid: ['../escape.png'],
});
expect(warnings).toContainEqual(
expect.objectContaining({ code: 'invalid-reference', path: '../escape.png' }),
);
});
it('flags missing doctype and viewport', () => {
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<html><body><h1>hi</h1></body></html>',
files: [],
});
const codes = warnings.map((w) => w.code).sort();
expect(codes).toEqual(['no-doctype', 'no-viewport']);
});
it('flags missing doctype even when a fake doctype lives inside a <script> string', () => {
const html =
'<html>' +
'<head><meta name="viewport" content="width=device-width">' +
'<script>const tpl = `<!doctype html><html></html>`;</script>' +
'</head><body><h1>hi</h1></body></html>';
const { warnings } = analyzeDeployPlan({ entryPath: 'index.html', html, files: [] });
expect(warnings.map((w: any) => w.code)).toContain('no-doctype');
});
it('accepts a doctype that follows a leading HTML comment and BOM', () => {
const html =
'<!-- generated 2026-05-02 -->\n<!doctype html>' +
'<meta name="viewport" content="width=device-width">' +
'<h1>hi</h1>';
const { warnings } = analyzeDeployPlan({ entryPath: 'index.html', html, files: [] });
expect(warnings.map((w: any) => w.code)).not.toContain('no-doctype');
});
it('flags external scripts and stylesheets', () => {
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html:
'<!doctype html><meta name="viewport" content="width=device-width">' +
'<link rel="stylesheet" href="https://cdn.test/x.css">' +
'<script src="https://cdn.test/x.js"></script>',
files: [],
});
const codes = warnings.map((w) => w.code).sort();
expect(codes).toEqual(['external-script', 'external-stylesheet']);
const ext = warnings.find((w) => w.code === 'external-script');
expect(ext?.url).toBe('https://cdn.test/x.js');
});
it('does not flag protocol-relative scripts as external when they are in fact external', () => {
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html:
'<!doctype html><meta name="viewport" content="width=device-width">' +
'<script src="//cdn.test/x.js"></script>',
files: [],
});
expect(warnings).toContainEqual(
expect.objectContaining({ code: 'external-script', url: '//cdn.test/x.js' }),
);
});
it('flags large per-file assets but not the entry HTML', () => {
const big = Buffer.alloc(DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES + 1);
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<!doctype html><meta name="viewport" content="width=device-width">',
files: [
{ file: 'index.html', data: Buffer.alloc(50), contentType: 'text/html', sourcePath: 'index.html' },
{ file: 'hero.jpg', data: big, contentType: 'image/jpeg', sourcePath: 'hero.jpg' },
],
});
expect(warnings).toContainEqual(
expect.objectContaining({ code: 'large-asset', path: 'hero.jpg' }),
);
expect(warnings.some((w) => w.code === 'large-html')).toBe(false);
});
it('flags large entry HTML', () => {
const huge = Buffer.alloc(DEPLOY_PREFLIGHT_LARGE_HTML_BYTES + 1);
const { warnings } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<!doctype html><meta name="viewport" content="width=device-width">',
files: [
{ file: 'index.html', data: huge, contentType: 'text/html', sourcePath: 'index.html' },
],
});
expect(warnings).toContainEqual(
expect.objectContaining({ code: 'large-html', path: 'index.html' }),
);
});
it('reports large-html against the source entry path, not the renamed deploy file', () => {
const huge = Buffer.alloc(DEPLOY_PREFLIGHT_LARGE_HTML_BYTES + 1);
const { warnings } = analyzeDeployPlan({
entryPath: 'pages/landing.html',
html: '<!doctype html><meta name="viewport" content="width=device-width">',
files: [
{ file: 'index.html', data: huge, contentType: 'text/html', sourcePath: 'pages/landing.html' },
],
});
const found = warnings.find((w: any) => w.code === 'large-html');
expect(found?.path).toBe('pages/landing.html');
});
it('returns no warnings on a healthy entry HTML', () => {
const { warnings, totalFiles, totalBytes } = analyzeDeployPlan({
entryPath: 'index.html',
html: '<!doctype html><meta name="viewport" content="width=device-width"><h1>Hello</h1>',
files: [
{ file: 'index.html', data: Buffer.from('<!doctype html><h1>Hello</h1>'), contentType: 'text/html', sourcePath: 'index.html' },
],
});
expect(warnings).toEqual([]);
expect(totalFiles).toBe(1);
expect(totalBytes).toBeGreaterThan(0);
});
it('preflight payload includes provider, entry, file list, totals and warnings', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await mkdir(path.join(dir, 'assets'));
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><meta name="viewport" content="width=device-width">' +
'<script src="https://cdn.test/x.js"></script>' +
'<img src="assets/logo.png">',
);
await writeFile(path.join(dir, 'assets', 'logo.png'), 'logo');
const result = await prepareDeployPreflight(projectsRoot, projectId, 'index.html');
expect(result.providerId).toBe('vercel-self');
expect(result.entry).toBe('index.html');
expect(result.totalFiles).toBe(2);
expect(result.totalBytes).toBeGreaterThan(0);
expect(result.files.map((f) => f.path).sort()).toEqual(['assets/logo.png', 'index.html']);
const codes = result.warnings.map((w) => w.code);
expect(codes).toContain('external-script');
expect(codes).not.toContain('broken-reference');
});
it('preflight preserves provider identity when requested', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
const result = await prepareDeployPreflight(projectsRoot, projectId, 'index.html', {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
});
expect(result.providerId).toBe(CLOUDFLARE_PAGES_PROVIDER_ID);
});
it('preflight reports broken references instead of throwing', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><meta name="viewport" content="width=device-width"><img src="missing.png">',
);
const result = await prepareDeployPreflight(projectsRoot, projectId, 'index.html');
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'broken-reference', path: 'missing.png' }),
);
expect(result.totalFiles).toBe(1);
});
it('preflight rejects non-html entry names', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'data.json'), '{}');
await expect(
prepareDeployPreflight(projectsRoot, projectId, 'data.json'),
).rejects.toThrow(/HTML/);
});
it('buildDeployFileSet still throws when missing or invalid refs exist', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'index.html'), '<img src="missing.png">');
await expect(
buildDeployFileSet(projectsRoot, projectId, 'index.html'),
).rejects.toMatchObject({ details: { missing: ['missing.png'] } });
});
});
describe('cloudflare pages deploys', () => {
function customDomainRequestInfo(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');
return { url, method };
}
function createCustomDomainDeployMock(options: {
dnsRecords?: Array<Record<string, unknown>>;
dnsRecordsAfterDuplicate?: Array<Record<string, unknown>>;
dnsCreateAlreadyExists?: boolean;
dnsCreateRejectsComment?: boolean;
pagesDomains?: Array<Record<string, unknown>>;
pagesDomainPages?: Array<Array<Record<string, unknown>>>;
customHeadStatus?: number;
} = {}) {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
let dnsCreateCount = 0;
let dnsLookupCount = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const { url, method } = customDomainRequestInfo(input, init);
calls.push({ url, method, body: init?.body });
if (url.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/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') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_custom', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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') {
dnsLookupCount += 1;
const result = options.dnsRecordsAfterDuplicate && dnsLookupCount > 1
? options.dnsRecordsAfterDuplicate
: options.dnsRecords ?? [];
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') {
dnsCreateCount += 1;
const body = JSON.parse(String(init?.body ?? '{}'));
if (options.dnsCreateRejectsComment && dnsCreateCount === 1) {
expect(body).toHaveProperty('comment');
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'comment is not allowed for this token' }],
}), {
status: 400,
headers: { 'content-type': 'application/json' },
});
}
if (options.dnsCreateAlreadyExists && dnsCreateCount === 1) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'DNS record already exists' }],
}), {
status: 409,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, result: { id: 'dns-1', ...body } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/zones/zone-1/dns_records/dns-1') && method === 'PATCH') {
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.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
const requestUrl = new URL(url);
const page = Number(requestUrl.searchParams.get('page') || '1');
const domainPages = options.pagesDomainPages;
const result = domainPages ? domainPages[page - 1] ?? [] : options.pagesDomains ?? [];
return new Response(JSON.stringify({
success: true,
result,
result_info: {
page,
per_page: 100,
total_pages: domainPages?.length || 1,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/domains') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { name: 'demo.example.com', status: 'active' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo.example.com' && method === 'HEAD') {
return new Response('', { status: options.customHeadStatus ?? 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
return { calls, fetchMock };
}
async function deployWithCustomDomain(options: {
priorMetadata?: Record<string, unknown>;
} = {}) {
return deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
priorMetadata: options.priorMetadata,
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
}
it('chunks asset uploads before posting to Cloudflare Pages', () => {
const chunks = chunkCloudflarePagesAssetUploads(
[
{ hash: 'a'.repeat(32), data: Buffer.from('one'), contentType: 'text/plain' },
{ hash: 'b'.repeat(32), data: Buffer.from('two'), contentType: 'text/plain' },
{ hash: 'c'.repeat(32), data: Buffer.from('three'), contentType: 'text/plain' },
],
{ maxFiles: 2, maxBytes: 10_000 },
);
expect(chunks.map((chunk) => chunk.map((file) => file.hash))).toEqual([
['a'.repeat(32), 'b'.repeat(32)],
['c'.repeat(32)],
]);
});
it('rejects Cloudflare Pages assets above the per-file upload limit', async () => {
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.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/upload-token') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
await expect(deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'huge.bin',
data: Buffer.alloc(CLOUDFLARE_PAGES_ASSET_MAX_BYTES + 1),
contentType: 'application/octet-stream',
sourcePath: 'huge.bin',
},
],
})).rejects.toThrow(/25\.00 MiB or smaller/);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('creates missing projects and uploads assets before submitting a manifest', async () => {
const requests: Array<{ url: string; method: string; body?: any; headers: Headers }> = [];
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
const assetHash = cloudflarePagesAssetHash({
file: 'assets/style.css',
data: Buffer.from('body { color: red; }'),
});
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');
const headers = new Headers(
init?.headers || (input instanceof Request ? input.headers : undefined),
);
requests.push({ url, method, body: init?.body, headers });
if (url.endsWith('/pages/projects/demo-pages') && 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).toEqual({
name: 'demo-pages',
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/demo-pages/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') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({
hashes: [indexHash, assetHash],
});
return new Response(JSON.stringify({ success: true, result: [indexHash, assetHash] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upload') && method === 'POST') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '[]'))).toEqual([
{
key: indexHash,
value: Buffer.from('hello index').toString('base64'),
metadata: { contentType: 'text/html' },
base64: true,
},
{
key: assetHash,
value: Buffer.from('body { color: red; }').toString('base64'),
metadata: { contentType: 'text/css' },
base64: true,
},
]);
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') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({
hashes: [indexHash, assetHash],
});
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
const form = init?.body as FormData;
expect(form).toBeInstanceOf(FormData);
const manifest = JSON.parse(String(form?.get('manifest') ?? '{}')) as Record<string, string>;
expect(form.get('branch')).toBe('main');
expect(form.get('pages_build_output_dir')).toBeNull();
expect(manifest).toEqual({
'/index.html': indexHash,
'/assets/style.css': assetHash,
});
expect(form.get(indexHash)).toBeNull();
expect(form.get(assetHash)).toBeNull();
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_123', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
{
file: 'assets/style.css',
data: Buffer.from('body { color: red; }'),
contentType: 'text/css',
sourcePath: 'assets/style.css',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'dep_123',
url: 'https://demo-pages.pages.dev',
status: 'ready',
});
expect(requests).toHaveLength(8);
expect(requests[0]?.headers.get('authorization')).toBe('Bearer cloudflare-token-secret');
});
it('treats concurrent Cloudflare Pages project creation races as already satisfied', async () => {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
let projectLookupCount = 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.endsWith('/pages/projects/demo-pages') && method === 'GET') {
projectLookupCount += 1;
if (projectLookupCount === 1) {
return new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects') && method === 'POST') {
return new Response(
JSON.stringify({ success: false, errors: [{ message: 'Project already exists' }] }),
{ status: 409, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/pages/projects/demo-pages/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') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_123', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'dep_123',
url: 'https://demo-pages.pages.dev',
status: 'ready',
});
expect(projectLookupCount).toBe(2);
});
it('rejects invalid custom-domain prefix before creating a Pages deployment', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'bad.prefix',
},
files: [],
})).rejects.toThrow(/valid subdomain prefix/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects stale Cloudflare zone selections before creating a Pages deployment', async () => {
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.endsWith('/zones/zone-1') && method === 'GET') {
return new Response(JSON.stringify({
success: true,
result: { id: 'zone-1', name: 'other.example', status: 'active', type: 'full' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
await expect(deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
files: [],
})).rejects.toThrow(/zone selection/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('paginates Cloudflare Pages zones for large accounts', async () => {
const pagesSeen: number[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
const requestUrl = new URL(url);
expect(requestUrl.pathname).toBe('/client/v4/zones');
expect(requestUrl.searchParams.get('account.id')).toBe('account_123');
expect(requestUrl.searchParams.get('per_page')).toBe('100');
const page = Number(requestUrl.searchParams.get('page') || '1');
pagesSeen.push(page);
const result = page === 1
? [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }]
: [{ id: 'zone-2', name: 'example.org', status: 'active', type: 'full' }];
return new Response(JSON.stringify({
success: true,
result,
result_info: { page, per_page: 100, total_pages: 2 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
await expect(listCloudflarePagesZones({
token: 'cloudflare-token-secret',
accountId: 'account_123',
cloudflarePages: { lastZoneId: 'zone-2' },
})).resolves.toEqual({
zones: [
{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' },
{ id: 'zone-2', name: 'example.org', status: 'active', type: 'full' },
],
cloudflarePages: { lastZoneId: 'zone-2' },
});
expect(pagesSeen).toEqual([1, 2]);
});
it('round-trips typed Cloudflare info while keeping provider metadata internal', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-deployment-db-test-'));
try {
const db = openDatabase(root, { dataDir: path.join(root, '.od') });
insertProject(db, {
id: 'project-1',
name: 'Project 1',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
});
const saved = upsertDeployment(db, {
id: 'deployment-1',
projectId: 'project-1',
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: 'https://demo-pages.pages.dev',
deploymentId: 'dep-1',
deploymentCount: 1,
target: 'preview',
status: 'link-delayed',
cloudflarePages: {
projectName: 'demo-pages',
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
url: 'https://demo.example.com',
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
status: 'pending',
dnsStatus: 'created',
dnsRecordId: 'dns-1',
dnsOwnership: 'marked',
domainStatus: 'pending',
},
},
providerMetadata: {
cloudflarePagesProjectName: 'demo-pages',
cloudflarePagesCustomDomain: {
projectId: 'project-1',
pagesProjectName: 'demo-pages',
hostname: 'demo.example.com',
marker: 'od:cfp:aaaaaaaaaaaa:bbbbbbbbbbbb',
dnsRecordId: 'dns-1',
},
},
createdAt: 1,
updatedAt: 2,
});
const loaded = getDeployment(db, 'project-1', 'index.html', CLOUDFLARE_PAGES_PROVIDER_ID);
if (!saved || !loaded) throw new Error('expected deployment roundtrip to be saved');
expect(saved).toMatchObject({
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
dnsRecordId: 'dns-1',
},
},
providerMetadata: {
cloudflarePagesProjectName: 'demo-pages',
cloudflarePagesCustomDomain: {
marker: 'od:cfp:aaaaaaaaaaaa:bbbbbbbbbbbb',
},
},
});
expect(loaded).toMatchObject(saved);
} finally {
closeDatabase();
await rm(root, { recursive: true, force: true });
}
});
it('creates a Cloudflare DNS CNAME and Pages custom domain while keeping pages.dev primary', async () => {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
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.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/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') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_custom', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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') {
expect(url).toContain('name=demo.example.com');
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 ?? '{}'));
expect(body).toMatchObject({
type: 'CNAME',
name: 'demo.example.com',
content: 'demo-pages.pages.dev',
proxied: true,
ttl: 1,
});
expect(body.comment).toMatch(/^od:cfp:[a-f0-9]{12}:[a-f0-9]{12}$/);
return new Response(JSON.stringify({ success: true, result: { id: 'dns-1', ...body } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/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: 'active' },
}), {
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);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: 'https://demo-pages.pages.dev',
status: 'ready',
cloudflarePages: {
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
url: 'https://demo.example.com',
status: 'ready',
dnsStatus: 'created',
dnsRecordId: 'dns-1',
dnsOwnership: 'marked',
domainStatus: 'active',
},
},
providerMetadata: {
cloudflarePagesProjectName: 'demo-pages',
cloudflarePagesCustomDomain: {
projectId: 'project-1',
pagesProjectName: 'demo-pages',
hostname: 'demo.example.com',
dnsRecordId: 'dns-1',
},
},
});
});
it('reuses an exact Cloudflare CNAME without mutating DNS', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
dnsRecords: [{
id: 'dns-existing',
type: 'CNAME',
name: 'demo.example.com',
content: 'demo-pages.pages.dev',
}],
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain();
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
dnsStatus: 'reused',
dnsRecordId: 'dns-existing',
dnsOwnership: 'unmarked',
},
},
});
expect(calls.some((call) => (
call.url.includes('/zones/zone-1/dns_records') &&
(call.method === 'POST' || call.method === 'PATCH')
))).toBe(false);
});
it('reuses a concurrently created CNAME after Cloudflare reports a duplicate', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
dnsRecords: [],
dnsCreateAlreadyExists: true,
dnsRecordsAfterDuplicate: [{
id: 'dns-race',
type: 'CNAME',
name: 'demo.example.com',
content: 'demo-pages.pages.dev',
}],
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain();
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
dnsStatus: 'reused',
dnsRecordId: 'dns-race',
dnsOwnership: 'unmarked',
},
},
});
expect(calls.filter((call) => call.url.includes('/zones/zone-1/dns_records?') && call.method === 'GET')).toHaveLength(2);
expect(calls.filter((call) => call.url.endsWith('/zones/zone-1/dns_records') && call.method === 'POST')).toHaveLength(1);
});
it('finds existing Cloudflare Pages custom domains beyond the first page', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
pagesDomainPages: [
[{ name: 'other.example.com', status: 'active' }],
[{ name: 'demo.example.com', status: 'active' }],
],
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain();
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
domainStatus: 'active',
},
},
});
const domainLookupUrls = calls
.filter((call) => call.url.includes('/pages/projects/demo-pages/domains?') && call.method === 'GET')
.map((call) => new URL(call.url).searchParams.get('page'));
expect(domainLookupUrls).toEqual(['1', '2']);
expect(calls.some((call) => call.url.endsWith('/pages/projects/demo-pages/domains') && call.method === 'POST')).toBe(false);
});
it('retries DNS creation without a comment when Cloudflare rejects comments', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
dnsCreateRejectsComment: true,
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain();
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
customDomain: {
status: 'ready',
dnsStatus: 'created',
dnsRecordId: 'dns-1',
dnsOwnership: 'unmarked',
},
},
});
const dnsCreateBodies = calls
.filter((call) => call.url.endsWith('/zones/zone-1/dns_records') && call.method === 'POST')
.map((call) => JSON.parse(String(call.body ?? '{}')));
expect(dnsCreateBodies).toHaveLength(2);
expect(dnsCreateBodies[0]).toHaveProperty('comment');
expect(dnsCreateBodies[1]).not.toHaveProperty('comment');
});
it('does not patch an unowned different-target CNAME', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
dnsRecords: [{
id: 'dns-external',
type: 'CNAME',
name: 'demo.example.com',
content: 'other.pages.dev',
}],
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain();
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
status: 'conflict',
errorCode: 'cloudflare_dns_record_conflict',
dnsStatus: 'conflict',
dnsRecordId: 'dns-external',
dnsOwnership: 'external',
domainStatus: 'skipped',
},
},
});
expect(calls.some((call) => call.method === 'PATCH')).toBe(false);
expect(calls.some((call) => call.url.endsWith('/pages/projects/demo-pages/domains') && call.method === 'POST')).toBe(false);
});
it('patches only a previously owned CNAME with matching marker metadata', async () => {
const initial = createCustomDomainDeployMock();
vi.stubGlobal('fetch', initial.fetchMock);
const first = await deployWithCustomDomain();
const priorMetadata = first.providerMetadata as Record<string, unknown>;
const priorCustom = priorMetadata.cloudflarePagesCustomDomain as Record<string, unknown>;
vi.unstubAllGlobals();
const { calls, fetchMock } = createCustomDomainDeployMock({
dnsRecords: [{
id: 'dns-1',
type: 'CNAME',
name: 'demo.example.com',
content: 'old-demo-pages.pages.dev',
comment: priorCustom.marker,
}],
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployWithCustomDomain({ priorMetadata });
expect(result).toMatchObject({
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
dnsStatus: 'patched',
dnsRecordId: 'dns-1',
dnsOwnership: 'marked',
},
},
});
const patchBodies = calls
.filter((call) => call.url.endsWith('/zones/zone-1/dns_records/dns-1') && call.method === 'PATCH')
.map((call) => JSON.parse(String(call.body ?? '{}')));
expect(patchBodies).toEqual([expect.objectContaining({
type: 'CNAME',
name: 'demo.example.com',
content: 'demo-pages.pages.dev',
comment: priorCustom.marker,
})]);
});
it('returns partial success with pages.dev when DNS custom-domain setup conflicts', async () => {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
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.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/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') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_conflict', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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: [{
id: 'dns-existing',
type: 'A',
name: 'demo.example.com',
content: '192.0.2.10',
}],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: 'https://demo-pages.pages.dev',
status: 'ready',
cloudflarePages: {
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
status: 'conflict',
errorCode: 'cloudflare_dns_record_conflict',
dnsStatus: 'conflict',
dnsRecordId: 'dns-existing',
domainStatus: 'skipped',
},
},
});
expect(fetchMock.mock.calls.some(([input, init]) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
return url.endsWith('/pages/projects/demo-pages/domains') && method === 'POST';
})).toBe(false);
});
it('returns partial success with pages.dev when Pages custom-domain binding conflicts', async () => {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
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.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/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') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_domain_conflict', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.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.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/domains') && method === 'POST') {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain already exists' }],
}), {
status: 409,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
projectId: 'project-1',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: 'https://demo-pages.pages.dev',
status: 'ready',
cloudflarePages: {
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
status: 'conflict',
errorCode: 'cloudflare_domain_already_bound',
dnsStatus: 'created',
dnsRecordId: 'dns-1',
domainStatus: 'conflict',
},
},
});
});
});
describe('deployment link readiness', () => {
async function withServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (url: string) => Promise<void>,
) {
const server = http.createServer(handler);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const address = server.address() as AddressInfo;
const url = `http://127.0.0.1:${address.port}`;
try {
await run(url);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
it('marks a reachable public URL as ready', async () => {
await withServer((_req, res) => {
res.writeHead(200);
res.end('ok');
}, async (url) => {
await expect(checkDeploymentUrl(url)).resolves.toMatchObject({ reachable: true });
});
});
it('keeps the URL when public link readiness times out', async () => {
const result = await waitForReachableDeploymentUrl(['http://127.0.0.1:9'], {
timeoutMs: 1,
intervalMs: 1,
});
expect(result).toMatchObject({
status: 'link-delayed',
url: 'http://127.0.0.1:9',
});
});
it('uses provider-specific copy for missing public URLs', async () => {
const result = await waitForReachableDeploymentUrl([], {
providerLabel: 'Cloudflare Pages',
});
expect(result).toMatchObject({
status: 'link-delayed',
statusMessage: 'Cloudflare Pages did not return a public deployment URL.',
});
});
it('marks a Vercel authentication page as protected', async () => {
await withServer((_req, res) => {
res.writeHead(401, {
server: 'Vercel',
'set-cookie': '_vercel_sso_nonce=test; Path=/; HttpOnly',
'content-type': 'text/html',
});
res.end('<title>Authentication Required</title><body>Vercel Authentication</body>');
}, async (url) => {
await expect(checkDeploymentUrl(url)).resolves.toMatchObject({
reachable: false,
status: 'protected',
});
});
});
it('returns protected without waiting for timeout', async () => {
await withServer((_req, res) => {
res.writeHead(401, { server: 'Vercel' });
res.end('Authentication Required');
}, async (url) => {
const result = await waitForReachableDeploymentUrl([url], {
timeoutMs: 5_000,
intervalMs: 1_000,
});
expect(result).toMatchObject({
status: 'protected',
url,
});
});
});
it('uses the first reachable candidate URL', async () => {
await withServer((_req, res) => {
res.writeHead(204);
res.end();
}, async (url) => {
const result = await waitForReachableDeploymentUrl(['http://127.0.0.1:9', url], {
timeoutMs: 100,
intervalMs: 1,
});
expect(result).toMatchObject({
status: 'ready',
url,
});
});
});
it('collects deployment URL aliases as candidates', () => {
expect(
deploymentUrlCandidates(
{ url: 'primary.vercel.app', alias: ['alias.vercel.app'] },
{ aliases: [{ domain: 'domain.vercel.app' }, 'plain.vercel.app'] },
),
).toEqual([
'https://primary.vercel.app',
'https://alias.vercel.app',
'https://domain.vercel.app',
'https://plain.vercel.app',
]);
});
it('recognizes Vercel protection signals', () => {
const headers = new Headers({
server: 'Vercel',
'set-cookie': '_vercel_sso_nonce=test',
});
expect(isVercelProtectedResponse({ headers }, 'Authentication Required')).toBe(true);
});
});