diff --git a/e2e/cloud-store.spec.ts b/e2e/cloud-store.spec.ts index c6bc233c..b16e9773 100644 --- a/e2e/cloud-store.spec.ts +++ b/e2e/cloud-store.spec.ts @@ -18,6 +18,9 @@ import { const LOCALHOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/ const CLOUD_BASE_ORIGIN = new URL(process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev').origin +const PUBLIC_BASE_ORIGIN = new URL( + process.env.E2E_PUBLIC_BASE_URL ?? process.env.E2E_BASE_URL ?? 'http://localhost:5185', +).origin type CloudProduct = { id: string @@ -57,10 +60,7 @@ test.describe await unbindCurrentCloudBinding() }) - test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({ - page, - baseURL, - }) => { + test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({ page }) => { test.setTimeout(420_000) await signInAsAdmin(page) @@ -85,7 +85,7 @@ test.describe await redeemGiftCard(page, giftCard.code) await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200) - const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin)) + const hasPublicCallbackUrl = !LOCALHOST_RE.test(PUBLIC_BASE_ORIGIN) if (!hasPublicCallbackUrl) { test.info().annotations.push({ type: 'checkout-delivery-skipped', diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index bc68f303..df18414e 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -3,13 +3,13 @@ * The webServer is already running when this executes. * Ensures an admin user and a storage backend exist. */ -import { request as playwrightRequest, test as setup } from '@playwright/test' +import { expect, request as playwrightRequest, test as setup } from '@playwright/test' import Database from 'better-sqlite3' import { hashPassword } from '../server/lib/password' import { ADMIN_EMAIL, ADMIN_PASSWORD } from './helpers' const localBaseUrl = process.env.E2E_LOCAL_BASE_URL ?? 'http://localhost:5185' -const publicBaseUrl = process.env.E2E_BASE_URL ?? localBaseUrl +const publicBaseUrl = process.env.E2E_PUBLIC_BASE_URL ?? process.env.E2E_BASE_URL ?? localBaseUrl const defaultOrgQuota = process.env.E2E_DEFAULT_ORG_QUOTA ?? String(1024 * 1024 * 1024) const storageConfig = { @@ -170,6 +170,7 @@ setup('seed admin and storage', async () => { const request = await playwrightRequest.newContext({ baseURL: localBaseUrl }) const headers = { Origin: localBaseUrl } try { + await expectPublicCallbackReady() prepareNodeDatabase() let authResp = await request.post('/api/auth/sign-in/email', { @@ -246,3 +247,21 @@ setup('seed admin and storage', async () => { await request.dispose() } }) + +async function expectPublicCallbackReady() { + if (publicBaseUrl === localBaseUrl) return + const request = await playwrightRequest.newContext({ baseURL: publicBaseUrl }) + try { + await expect + .poll( + async () => { + const response = await request.get('/api/health') + return response.ok() ? ((await response.json()) as { status?: string }).status : await response.text() + }, + { message: `public callback URL did not become ready: ${publicBaseUrl}`, timeout: 60_000 }, + ) + .toBe('ok') + } finally { + await request.dispose() + } +} diff --git a/scripts/cloud-e2e-resilience.mjs b/scripts/cloud-e2e-resilience.mjs index 006fd696..43d51ce5 100644 --- a/scripts/cloud-e2e-resilience.mjs +++ b/scripts/cloud-e2e-resilience.mjs @@ -3,6 +3,24 @@ const CLIENT_TRANSPORT_FAILURE = /(?:\b502\b|ERR_(?:FAILED|TUNNEL_CONNECTION_FAI const TUNNEL_CONTEXT_CANCELED = /(?:Incoming request ended abruptly|Request failed)[^\n]*context canceled/i const QUICK_TUNNEL_REQUEST = /trycloudflare\.com/i +export class CloudE2eCommandError extends Error { + constructor(command, commandArgs, code, output) { + super(`${command} ${commandArgs.join(' ')} exited with ${code}`) + this.output = output + } +} + +export function cloudE2eEndpoints(localBaseUrl, tunnelUrl) { + return { + browserBaseUrl: localBaseUrl, + publicBaseUrl: tunnelUrl ?? localBaseUrl, + } +} + +export function cloudflaredQuickTunnelArgs(target) { + return ['tunnel', '--url', target, '--protocol', 'http2', '--no-autoupdate'] +} + export function isRetryableQuickTunnelFailure({ commandOutput, tunnelOutput }) { if (QUICK_TUNNEL_502.test(commandOutput)) return true return ( diff --git a/scripts/cloud-e2e-resilience.test.mjs b/scripts/cloud-e2e-resilience.test.mjs index 42c952e0..170c38cf 100644 --- a/scripts/cloud-e2e-resilience.test.mjs +++ b/scripts/cloud-e2e-resilience.test.mjs @@ -1,7 +1,44 @@ import { describe, expect, it } from 'vitest' -import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs' +import { + CloudE2eCommandError, + cloudE2eAttemptCount, + cloudE2eEndpoints, + cloudflaredQuickTunnelArgs, + isRetryableQuickTunnelFailure, +} from './cloud-e2e-resilience.mjs' describe('cloud E2E resilience', () => { + it('provides the command error type before the runner executes', () => { + const error = new CloudE2eCommandError('node', ['playwright', 'test'], 1, 'gateway response') + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(CloudE2eCommandError) + expect(error.message).toBe('node playwright test exited with 1') + expect(error.output).toBe('gateway response') + }) + + it('keeps browser traffic local and reserves the tunnel for public callbacks', () => { + expect(cloudE2eEndpoints('http://localhost:5185', 'https://callback.trycloudflare.com')).toEqual({ + browserBaseUrl: 'http://localhost:5185', + publicBaseUrl: 'https://callback.trycloudflare.com', + }) + expect(cloudE2eEndpoints('http://localhost:5185', null)).toEqual({ + browserBaseUrl: 'http://localhost:5185', + publicBaseUrl: 'http://localhost:5185', + }) + }) + + it('uses HTTP/2 instead of QUIC for the Quick Tunnel transport', () => { + expect(cloudflaredQuickTunnelArgs('http://localhost:5185')).toEqual([ + 'tunnel', + '--url', + 'http://localhost:5185', + '--protocol', + 'http2', + '--no-autoupdate', + ]) + }) + it('retries a Cloudflare Quick Tunnel gateway page', () => { expect( isRetryableQuickTunnelFailure({ diff --git a/scripts/run-cloud-e2e.mjs b/scripts/run-cloud-e2e.mjs index 5209851c..9d48dc9f 100644 --- a/scripts/run-cloud-e2e.mjs +++ b/scripts/run-cloud-e2e.mjs @@ -2,7 +2,13 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { spawn } from 'node:child_process' import { Resolver } from 'node:dns/promises' import { createRequire } from 'node:module' -import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs' +import { + CloudE2eCommandError, + cloudE2eAttemptCount, + cloudE2eEndpoints, + cloudflaredQuickTunnelArgs, + isRetryableQuickTunnelFailure, +} from './cloud-e2e-resilience.mjs' const args = process.argv.slice(2) const require = createRequire(import.meta.url) @@ -49,7 +55,7 @@ for (let attempt = 1; attempt <= maxRunAttempts; attempt += 1) { break } catch (error) { const retryable = - error instanceof CommandError && + error instanceof CloudE2eCommandError && tunnel && isRetryableQuickTunnelFailure({ commandOutput: error.output, @@ -67,17 +73,17 @@ for (let attempt = 1; attempt <= maxRunAttempts; attempt += 1) { async function buildE2eEnv(tunnel) { const tunnelHost = tunnel ? new URL(tunnel.url).hostname : '' - const tunnelIp = tunnel ? await waitForPublicTunnelIp(tunnelHost) : '' - const baseUrl = tunnel?.url ?? localBaseUrl + if (tunnel) await waitForPublicTunnelIp(tunnelHost) + const { browserBaseUrl, publicBaseUrl } = cloudE2eEndpoints(localBaseUrl, tunnel?.url) return { ...cloudEnv, - E2E_BASE_URL: baseUrl, + E2E_BASE_URL: browserBaseUrl, E2E_LOCAL_BASE_URL: localBaseUrl, + E2E_PUBLIC_BASE_URL: publicBaseUrl, E2E_APP_PORT: String(appPort), E2E_API_PORT: String(apiPort), - BETTER_AUTH_URL: baseUrl, - TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`, - ...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}), + BETTER_AUTH_URL: publicBaseUrl, + TRUSTED_ORIGINS: `${publicBaseUrl},${localBaseUrl}`, ...s3MockEnv(), ...credentialsEnv, ...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}), @@ -141,7 +147,7 @@ async function startTunnel(target) { } function startTunnelOnce(target) { - const child = spawn(cloudflared, ['tunnel', '--url', target, '--no-autoupdate'], { + const child = spawn(cloudflared, cloudflaredQuickTunnelArgs(target), { stdio: ['ignore', 'pipe', 'pipe'], }) writeFileSync(pidFile, String(child.pid)) @@ -227,13 +233,6 @@ async function waitForPublicTunnelIp(hostname) { throw new Error(`Timed out waiting for public tunnel DNS: ${hostname}`) } -class CommandError extends Error { - constructor(command, commandArgs, code, output) { - super(`${command} ${commandArgs.join(' ')} exited with ${code}`) - this.output = output - } -} - function run(command, commandArgs, env = {}, captureOutput = false) { return new Promise((resolve, reject) => { const child = spawn(command, commandArgs, { @@ -253,7 +252,7 @@ function run(command, commandArgs, env = {}, captureOutput = false) { } child.on('exit', (code) => { if (code === 0) resolve() - else reject(new CommandError(command, commandArgs, code, output)) + else reject(new CloudE2eCommandError(command, commandArgs, code, output)) }) }) }