mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
Merge remote-tracking branch 'origin/main' into fix/dpop-verification-challenge
This commit is contained in:
@@ -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',
|
||||
|
||||
+21
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
+16
-17
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user