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'), '