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 b7abe053..43d51ce5 100644 --- a/scripts/cloud-e2e-resilience.mjs +++ b/scripts/cloud-e2e-resilience.mjs @@ -10,6 +10,17 @@ export class CloudE2eCommandError extends Error { } } +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 818c7a2b..170c38cf 100644 --- a/scripts/cloud-e2e-resilience.test.mjs +++ b/scripts/cloud-e2e-resilience.test.mjs @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { CloudE2eCommandError, cloudE2eAttemptCount, + cloudE2eEndpoints, + cloudflaredQuickTunnelArgs, isRetryableQuickTunnelFailure, } from './cloud-e2e-resilience.mjs' @@ -15,6 +17,28 @@ describe('cloud E2E resilience', () => { 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 1fc3d68f..9d48dc9f 100644 --- a/scripts/run-cloud-e2e.mjs +++ b/scripts/run-cloud-e2e.mjs @@ -5,6 +5,8 @@ import { createRequire } from 'node:module' import { CloudE2eCommandError, cloudE2eAttemptCount, + cloudE2eEndpoints, + cloudflaredQuickTunnelArgs, isRetryableQuickTunnelFailure, } from './cloud-e2e-resilience.mjs' @@ -71,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' } : {}), @@ -145,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))