diff --git a/mise-tasks/lib/env-vars.sh b/mise-tasks/lib/env-vars.sh index b5738f5b81..637d3d3f9e 100755 --- a/mise-tasks/lib/env-vars.sh +++ b/mise-tasks/lib/env-vars.sh @@ -54,6 +54,14 @@ if [ -n "${BOXEL_ENVIRONMENT:-}" ]; then # Paths export REALMS_ROOT="./realms/${ENV_SLUG}" export REALMS_TEST_ROOT="./realms/${ENV_SLUG}_test" + + # Matrix test services (isolated realm server + worker for Playwright tests) + export MATRIX_TEST_REALM_URL="http://realm-matrix-test.${ENV_SLUG}.localhost" + export MATRIX_TEST_REALM_PORT=0 + export MATRIX_TEST_WORKER_PORT=0 + export MATRIX_TEST_PUBLISHED_DOMAIN="realm-matrix-test.${ENV_SLUG}.localhost" + export SMTP_URL="http://smtp.${ENV_SLUG}.localhost" + export SMTP_PORT=0 else # Capture previous ENV_MODE before resetting it, so we can detect transitions _PREV_ENV_MODE="${ENV_MODE:-}" @@ -91,6 +99,14 @@ else # Paths export REALMS_ROOT="./realms/localhost_4201" export REALMS_TEST_ROOT="./realms/localhost_4202" + + # Matrix test services + export MATRIX_TEST_REALM_URL="http://localhost:4205" + export MATRIX_TEST_REALM_PORT=4205 + export MATRIX_TEST_WORKER_PORT=4232 + export MATRIX_TEST_PUBLISHED_DOMAIN="localhost:4205" + export SMTP_URL="http://localhost:5001" + export SMTP_PORT=5001 else # Fresh standard mode or non-env-mode shell: # use :- so production/staging env vars are not clobbered. @@ -122,6 +138,14 @@ else # Paths export REALMS_ROOT="${REALMS_ROOT:-./realms/localhost_4201}" export REALMS_TEST_ROOT="${REALMS_TEST_ROOT:-./realms/localhost_4202}" + + # Matrix test services + export MATRIX_TEST_REALM_URL="${MATRIX_TEST_REALM_URL:-http://localhost:4205}" + export MATRIX_TEST_REALM_PORT="${MATRIX_TEST_REALM_PORT:-4205}" + export MATRIX_TEST_WORKER_PORT="${MATRIX_TEST_WORKER_PORT:-4232}" + export MATRIX_TEST_PUBLISHED_DOMAIN="${MATRIX_TEST_PUBLISHED_DOMAIN:-localhost:4205}" + export SMTP_URL="${SMTP_URL:-http://localhost:5001}" + export SMTP_PORT="${SMTP_PORT:-5001}" fi unset _PREV_ENV_MODE diff --git a/mise-tasks/services/realm-server-base b/mise-tasks/services/realm-server-base index 63541c4459..b6a80d0727 100755 --- a/mise-tasks/services/realm-server-base +++ b/mise-tasks/services/realm-server-base @@ -1,6 +1,6 @@ #!/bin/sh #MISE description="Start base realm server only" -#MISE depends=["infra:ensure-pg"] +#MISE depends=["infra:ensure-traefik", "infra:ensure-pg"] #MISE dir="packages/realm-server" if [ -z "$MATRIX_REGISTRATION_SHARED_SECRET" ]; then @@ -8,10 +8,16 @@ if [ -z "$MATRIX_REGISTRATION_SHARED_SECRET" ]; then export MATRIX_REGISTRATION_SHARED_SECRET fi +if [ -n "$ENV_MODE" ]; then + WORKER_MANAGER_ARG="--workerManagerUrl=${WORKER_MGR_URL}" +else + WORKER_MANAGER_ARG="$1" +fi + NODE_ENV=development \ NODE_NO_WARNINGS=1 \ PGPORT="${PGPORT}" \ - PGDATABASE=boxel_base \ + PGDATABASE="${PGDATABASE:-boxel_base}" \ REALM_SERVER_SECRET_SEED="mum's the word" \ REALM_SECRET_SEED="shhh! it's a secret" \ GRAFANA_SECRET="shhh! it's a secret" \ @@ -19,14 +25,14 @@ NODE_ENV=development \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ts-node \ --transpileOnly main \ - --port=4201 \ + --port="${REALM_PORT:-4201}" \ --matrixURL="${MATRIX_URL_VAL}" \ - --realmsRootPath='./realms/localhost_4201_base' \ + --realmsRootPath="${REALMS_ROOT:-./realms/localhost_4201_base}" \ --prerendererUrl="${PRERENDER_URL}" \ --migrateDB \ - $1 \ + $WORKER_MANAGER_ARG \ \ --path='../base' \ --username='base_realm' \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' + --toUrl="${REALM_BASE_URL:-http://localhost:4201}/base/" diff --git a/mise-tasks/services/worker-base b/mise-tasks/services/worker-base index 0f6aa184d6..2523ba7456 100755 --- a/mise-tasks/services/worker-base +++ b/mise-tasks/services/worker-base @@ -1,21 +1,21 @@ #!/bin/sh #MISE description="Start worker manager for base realm only" -#MISE depends=["infra:ensure-pg", "infra:wait-for-prerender"] +#MISE depends=["infra:ensure-traefik", "infra:ensure-pg", "infra:wait-for-prerender"] #MISE dir="packages/realm-server" NODE_ENV=development \ NODE_NO_WARNINGS=1 \ NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}" \ PGPORT="${PGPORT}" \ - PGDATABASE=boxel_base \ + PGDATABASE="${PGDATABASE:-boxel_base}" \ REALM_SECRET_SEED="shhh! it's a secret" \ REALM_SERVER_MATRIX_USERNAME=realm_server \ LOW_CREDIT_THRESHOLD=2000 \ ts-node \ --transpileOnly worker-manager \ - --port=4213 \ + --port="${WORKER_PORT:-4213}" \ --matrixURL="${MATRIX_URL_VAL}" \ --prerendererUrl="${PRERENDER_MGR_URL}" \ \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' + --toUrl="${REALM_BASE_URL:-http://localhost:4201}/base/" diff --git a/mise-tasks/test-matrix b/mise-tasks/test-matrix new file mode 100755 index 0000000000..7ba01e7e10 --- /dev/null +++ b/mise-tasks/test-matrix @@ -0,0 +1,20 @@ +#!/bin/sh +#MISE description="Run Playwright matrix tests (environment-aware)" +#MISE dir="packages/matrix" + +# Usage: mise run test-matrix [shard] +# In environment mode, uses Traefik-routed URLs; otherwise uses fixed ports. + +shard_flag=${1:+--shard} + +BASE_REALM_HOST="${REALM_BASE_URL:-http://localhost:4201}" +READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" +BASE_REALM_READY="http-get://${BASE_REALM_HOST#http://}/base/${READY_PATH}" + +echo "Waiting for base realm at ${BASE_REALM_HOST}..." +echo "Running matrix tests${1:+ (shard: $1)}" + +WAIT_ON_TIMEOUT=600000 pnpm exec start-server-and-test \ + 'pnpm run wait' \ + "$BASE_REALM_READY" \ + "pnpm exec playwright test ${shard_flag} ${1}" diff --git a/mise-tasks/test-services-matrix b/mise-tasks/test-services-matrix index 3f8945eaa9..6533b67a9e 100755 --- a/mise-tasks/test-services-matrix +++ b/mise-tasks/test-services-matrix @@ -8,7 +8,7 @@ READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" BASE_REALM_READY="http-get://${REALM_BASE_URL#http://}/base/${READY_PATH}" WAIT_ON_TIMEOUT=600000 NODE_NO_WARNINGS=1 SKIP_SUBMISSION=true \ - start-server-and-test \ - 'run-p -ln start:pg start:icons start:prerender-dev start:prerender-manager-dev start:worker-base start:base' \ + pnpm exec start-server-and-test \ + 'pnpm exec run-p -ln start:pg start:icons start:prerender-dev start:prerender-manager-dev start:worker-base start:base' \ "${BASE_REALM_READY}|${ICONS_URL}" \ 'wait' diff --git a/packages/matrix/docker/smtp4dev.ts b/packages/matrix/docker/smtp4dev.ts index 2da7690aec..0e30d7f91f 100644 --- a/packages/matrix/docker/smtp4dev.ts +++ b/packages/matrix/docker/smtp4dev.ts @@ -1,10 +1,31 @@ import { dockerCreateNetwork, dockerRun, dockerStop, dockerRm } from './index'; +import { + isEnvironmentMode, + getEnvironmentSlug, + registerServiceWithTraefik, + deregisterServiceFromTraefik, +} from '../helpers/environment-config'; +import { execSync } from 'child_process'; interface Options { mailClientPort?: number; + traefikServiceName?: string; +} + +let _smtpServiceName = 'smtp'; + +function smtpContainerName(): string { + if (isEnvironmentMode()) { + return `boxel-${_smtpServiceName}-${getEnvironmentSlug()}`; + } + return 'boxel-smtp'; } export async function smtpStart(opts?: Options) { + if (opts?.traefikServiceName) { + _smtpServiceName = opts.traefikServiceName; + } + let containerName = smtpContainerName(); try { await smtpStop(); } catch (e: any) { @@ -12,22 +33,40 @@ export async function smtpStart(opts?: Options) { throw e; } } - let mailClientPort = opts?.mailClientPort ?? 5001; - let portMapping = `${mailClientPort}:80`; + let envMode = isEnvironmentMode(); + let mailClientPort = envMode + ? 0 + : (opts?.mailClientPort ?? parseInt(process.env.SMTP_PORT || '5001', 10)); + let portMapping = envMode ? '0:80' : `${mailClientPort}:80`; await dockerCreateNetwork({ networkName: 'boxel' }); const containerId = await dockerRun({ image: 'rnwood/smtp4dev:v3.1', - containerName: 'boxel-smtp', + containerName, dockerParams: ['-p', portMapping, '--network=boxel'], }); - console.log( - `Started smtp4dev with id ${containerId} mapped to host port ${mailClientPort}.`, - ); + if (envMode) { + let portOutput = execSync(`docker port ${containerId} 80/tcp`, { + encoding: 'utf-8', + }).trim(); + let hostPort = parseInt(portOutput.split('\n')[0].split(':').pop()!, 10); + registerServiceWithTraefik(_smtpServiceName, hostPort); + console.log( + `Started smtp4dev with id ${containerId} on dynamic port ${hostPort} (Traefik).`, + ); + } else { + console.log( + `Started smtp4dev with id ${containerId} mapped to host port ${mailClientPort}.`, + ); + } return containerId; } export async function smtpStop() { - await dockerStop({ containerId: 'boxel-smtp' }); - await dockerRm({ containerId: 'boxel-smtp' }); + let containerName = smtpContainerName(); + if (isEnvironmentMode()) { + deregisterServiceFromTraefik(_smtpServiceName); + } + await dockerStop({ containerId: containerName }); + await dockerRm({ containerId: containerName }); } diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index 095de13bb6..3d6fd662c6 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -16,7 +16,7 @@ import { isEnvironmentMode, getSynapseContainerName, getSynapseURL, - registerSynapseWithTraefik, + registerServiceWithTraefik, } from '../../helpers/environment-config'; export const SYNAPSE_IP_ADDRESS = '172.20.0.5'; @@ -110,6 +110,7 @@ interface StartOptions { dataDir?: string; containerName?: string; suppressRegistrationSecretFile?: true; + traefikServiceName?: string; } export async function synapseStart( opts?: StartOptions, @@ -194,7 +195,8 @@ export async function synapseStart( let firstLine = portOutput.split('\n')[0]; let hostPort = parseInt(firstLine.split(':').pop()!, 10); console.log(`Synapse dynamic host port: ${hostPort}`); - registerSynapseWithTraefik(hostPort); + let synapseServiceName = opts?.traefikServiceName || 'matrix'; + registerServiceWithTraefik(synapseServiceName, hostPort); } const synapse: SynapseInstance = { synapseId, ...synCfg }; diff --git a/packages/matrix/helpers/environment-config.ts b/packages/matrix/helpers/environment-config.ts index dff1ad4f6e..15622c6fcb 100644 --- a/packages/matrix/helpers/environment-config.ts +++ b/packages/matrix/helpers/environment-config.ts @@ -64,7 +64,16 @@ export function getSynapseContainerName(): string { return 'boxel-synapse'; } +let _synapseURLOverride: string | undefined; + +export function setSynapseURL(url: string): void { + _synapseURLOverride = url; +} + export function getSynapseURL(): string { + if (_synapseURLOverride) { + return _synapseURLOverride; + } if (!isEnvironmentMode()) { return 'http://localhost:8008'; } @@ -83,9 +92,11 @@ export function getSynapseURL(): string { } } -export function registerSynapseWithTraefik(hostPort: number): void { +export function registerServiceWithTraefik( + serviceName: string, + hostPort: number, +): void { let slug = getEnvironmentSlug(); - let serviceName = 'matrix'; let configPath = join(traefikDynamicDir(), `${slug}-${serviceName}.yml`); let routerKey = `${serviceName}-${slug}`; let hostname = `${serviceName}.${slug}.${DOMAIN}`; @@ -111,28 +122,38 @@ export function registerSynapseWithTraefik(hostPort: number): void { atomicWrite(configPath, yaml.stringify(config)); console.log( - `Registered Synapse at ${hostname} -> localhost:${hostPort}`, + `Registered ${serviceName} at ${hostname} -> localhost:${hostPort}`, ); } -export function deregisterSynapseFromTraefik(): void { +export function registerSynapseWithTraefik(hostPort: number): void { + registerServiceWithTraefik('matrix', hostPort); +} + +export function deregisterServiceFromTraefik(serviceName: string): void { if (!isEnvironmentMode()) { return; } let slug = getEnvironmentSlug(); - let configPath = join(traefikDynamicDir(), `${slug}-matrix.yml`); + let configPath = join(traefikDynamicDir(), `${slug}-${serviceName}.yml`); try { unlinkSync(configPath); - console.log(`Deregistered Synapse for environment ${slug} from Traefik`); + console.log( + `Deregistered ${serviceName} for environment ${slug} from Traefik`, + ); } catch (e: any) { if (e.code !== 'ENOENT') { console.error( - `Failed to deregister Synapse for environment ${slug}: ${e.message}`, + `Failed to deregister ${serviceName} for environment ${slug}: ${e.message}`, ); } } } +export function deregisterSynapseFromTraefik(): void { + deregisterServiceFromTraefik('matrix'); +} + function atomicWrite(filePath: string, content: string): void { let tmpPath = `${filePath}.tmp`; writeFileSync(tmpPath, content, 'utf-8'); diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index ec87953e03..eedca174bf 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -12,12 +12,24 @@ import { } from '../docker/synapse'; import { realmPassword } from './realm-credentials'; import type { SQLExecutor } from './isolated-realm-server'; -import { appURL, BasicSQLExecutor } from './isolated-realm-server'; +import { + appURL, + serverIndexUrl, + realmDomain, + BasicSQLExecutor, +} from './isolated-realm-server'; +import { + isEnvironmentMode, + getEnvironmentSlug, +} from './environment-config'; import { APP_BOXEL_MESSAGE_MSGTYPE } from './matrix-constants'; import { randomUUID } from 'crypto'; -export const testHost = 'http://localhost:4205/test'; -export const mailHost = 'http://localhost:5001'; +export { realmDomain, serverIndexUrl }; +export const testHost = appURL; +export const mailHost = isEnvironmentMode() + ? `http://smtp-test.${getEnvironmentSlug()}.localhost` + : 'http://localhost:5001'; export const initialRoomName = 'New AI Assistant Chat'; export const REGISTRATION_TOKEN = 'abc123'; @@ -108,15 +120,18 @@ async function registerRealmRedirect( } export async function setRealmRedirects(page: Page) { + let baseServerUrl = isEnvironmentMode() + ? `http://realm-server.${getEnvironmentSlug()}.localhost` + : 'http://localhost:4201'; await registerRealmRedirect( page, - 'http://localhost:4201/skills/', - 'http://localhost:4205/skills/', + `${baseServerUrl}/skills/`, + `${serverIndexUrl}/skills/`, ); await registerRealmRedirect( page, - 'http://localhost:4201/base/', - 'http://localhost:4205/base/', + `${baseServerUrl}/base/`, + `${serverIndexUrl}/base/`, ); } diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index e4055dd79b..e0705132e6 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -6,6 +6,12 @@ import { ensureDirSync, copySync, readFileSync } from 'fs-extra'; import { Pool } from 'pg'; import { createServer as createNetServer, type AddressInfo } from 'net'; import type { SynapseInstance } from '../docker/synapse'; +import { + isEnvironmentMode, + getEnvironmentSlug, + registerServiceWithTraefik, + deregisterServiceFromTraefik, +} from './environment-config'; setGracefulCleanup(); @@ -18,7 +24,20 @@ const skillsRealmDir = resolve( ); const baseRealmDir = resolve(join(__dirname, '..', '..', 'base')); const matrixDir = resolve(join(__dirname, '..')); -export const appURL = 'http://localhost:4205/test'; + +const ISOLATED_REALM_SERVICE = 'realm-matrix-test'; +const ISOLATED_WORKER_SERVICE = 'worker-matrix-test'; +const ISOLATED_PRERENDER_SERVICE = 'prerender-matrix-test'; + +// Compute URLs from BOXEL_ENVIRONMENT directly so that setting just that +// one env var is sufficient — no need to source env-vars.sh first. +const envMode = isEnvironmentMode(); +const envSlug = envMode ? getEnvironmentSlug() : ''; +export const serverIndexUrl = envMode + ? `http://${ISOLATED_REALM_SERVICE}.${envSlug}.localhost` + : 'http://localhost:4205'; +export const appURL = `${serverIndexUrl}/test`; +export const realmDomain = serverIndexUrl.replace(/^https?:\/\//, ''); const DEFAULT_PRERENDER_PORT = 4231; @@ -53,7 +72,8 @@ async function isPortAvailable(port: number): Promise { } async function findAvailablePort(preferred?: number): Promise { - if (typeof preferred === 'number' && (await isPortAvailable(preferred))) { + // port 0 means "pick any available port" — skip straight to dynamic allocation + if (typeof preferred === 'number' && preferred > 0 && (await isPortAvailable(preferred))) { return preferred; } return await new Promise((resolve, reject) => { @@ -136,14 +156,22 @@ function stopChildProcess( export async function startPrerenderServer( options?: PrerenderServerConfig, ): Promise { - let port = await findAvailablePort(options?.port ?? DEFAULT_PRERENDER_PORT); - let url = `http://localhost:${port}`; + let preferredPort = envMode ? 0 : (options?.port ?? DEFAULT_PRERENDER_PORT); + let port = await findAvailablePort(preferredPort); + let localUrl = `http://localhost:${port}`; let silent = process.env.SOFTWARE_FACTORY_PRERENDER_SILENT !== '0'; let env = { ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - BOXEL_HOST_URL: process.env.HOST_URL ?? 'http://localhost:4200', + // Point the prerender at the host app directly (not the isolated realm + // server) to avoid a deadlock: standby creation needs the host app shell, + // and the realm server's indexing needs the prerender to be ready. + BOXEL_HOST_URL: envMode + ? `http://host.${envSlug}.localhost` + : (process.env.HOST_URL ?? 'http://localhost:4200'), + // Use a distinct service name so it doesn't overwrite the dev prerender + PRERENDER_SERVICE_NAME: ISOLATED_PRERENDER_SERVICE, LOG_LEVELS: process.env.SOFTWARE_FACTORY_PRERENDER_LOG_LEVELS ?? process.env.LOG_LEVELS, }; @@ -190,7 +218,7 @@ export async function startPrerenderServer( }); try { - await Promise.race([waitForHttpReady(url, 60_000), exitPromise]); + await Promise.race([waitForHttpReady(localUrl, 60_000), exitPromise]); } finally { if (exitListener) { child.removeListener('exit', exitListener); @@ -200,10 +228,22 @@ export async function startPrerenderServer( } } + // In env mode, register with Traefik so parallel environments don't collide + let url: string; + if (envMode) { + registerServiceWithTraefik(ISOLATED_PRERENDER_SERVICE, port); + url = `http://${ISOLATED_PRERENDER_SERVICE}.${envSlug}.localhost`; + } else { + url = localUrl; + } + return { port, url, async stop() { + if (envMode) { + deregisterServiceFromTraefik(ISOLATED_PRERENDER_SERVICE); + } await stopChildProcess(child); }, }; @@ -219,15 +259,35 @@ export async function startServer({ copySync(testRealmCards, testRealmDir); let testDBName = `test_db_${Math.floor(10000000 * Math.random())}`; - let workerManagerPort = await findAvailablePort(4232); + let envMode = isEnvironmentMode(); + let preferredWorkerPort = envMode + ? 0 + : parseInt(process.env.MATRIX_TEST_WORKER_PORT || '4232', 10); + let workerManagerPort = await findAvailablePort(preferredWorkerPort); + let preferredRealmPort = envMode + ? 0 + : parseInt(process.env.MATRIX_TEST_REALM_PORT || '4205', 10); + let realmPort = await findAvailablePort(preferredRealmPort); + + let publishedDomain = + process.env.MATRIX_TEST_PUBLISHED_DOMAIN || realmDomain; + + // Register with Traefik BEFORE spawning processes so the worker can + // reach the realm server via the Traefik hostname from the start. + if (envMode) { + registerServiceWithTraefik(ISOLATED_REALM_SERVICE, realmPort); + registerServiceWithTraefik(ISOLATED_WORKER_SERVICE, workerManagerPort); + } - process.env.PGPORT = '5435'; + process.env.PGPORT = process.env.PGPORT || '5435'; process.env.PGDATABASE = testDBName; process.env.NODE_NO_WARNINGS = '1'; process.env.REALM_SERVER_SECRET_SEED = "mum's the word"; process.env.REALM_SECRET_SEED = "shhh! it's a secret"; process.env.GRAFANA_SECRET = "shhh! it's a secret"; - let matrixURL = `http://localhost:${synapse.port}`; + let matrixURL = envMode + ? `http://matrix-test.${envSlug}.localhost` + : `http://localhost:${synapse.port}`; process.env.MATRIX_URL = matrixURL; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; process.env.NODE_ENV = 'test'; @@ -240,13 +300,15 @@ export async function startServer({ `--matrixURL='${matrixURL}'`, `--prerendererUrl='${prerenderURL}'`, `--migrateDB`, + // Use a distinct service name so the worker doesn't overwrite the dev worker + ...(envMode ? [`--serviceName='${ISOLATED_WORKER_SERVICE}'`] : []), - `--fromUrl='http://localhost:4205/test/'`, - `--toUrl='http://localhost:4205/test/'`, + `--fromUrl='${serverIndexUrl}/test/'`, + `--toUrl='${serverIndexUrl}/test/'`, ]; workerArgs = workerArgs.concat([ `--fromUrl='https://cardstack.com/base/'`, - `--toUrl='http://localhost:4205/base/'`, + `--toUrl='${serverIndexUrl}/base/'`, ]); let workerManager = spawn('ts-node', workerArgs, { @@ -267,29 +329,31 @@ export async function startServer({ let serverArgs = [ `--transpileOnly`, 'main', - `--port=4205`, + `--port=${realmPort}`, `--matrixURL='${matrixURL}'`, `--realmsRootPath='${dir.name}'`, `--workerManagerPort=${workerManagerPort}`, - `--prerendererUrl="${prerenderURL}"`, + `--prerendererUrl='${prerenderURL}'`, `--useRegistrationSecretFunction`, + // Use a distinct service name so it doesn't overwrite the dev realm server + ...(envMode ? [`--serviceName='${ISOLATED_REALM_SERVICE}'`] : []), `--path='${testRealmDir}'`, `--username='test_realm'`, - `--fromUrl='http://localhost:4205/test/'`, - `--toUrl='http://localhost:4205/test/'`, + `--fromUrl='${serverIndexUrl}/test/'`, + `--toUrl='${serverIndexUrl}/test/'`, ]; serverArgs = serverArgs.concat([ `--username='skills_realm'`, `--path='${skillsRealmDir}'`, - `--fromUrl='http://localhost:4205/skills/'`, - `--toUrl='http://localhost:4205/skills/'`, + `--fromUrl='${serverIndexUrl}/skills/'`, + `--toUrl='${serverIndexUrl}/skills/'`, ]); serverArgs = serverArgs.concat([ `--username='base_realm'`, `--path='${baseRealmDir}'`, `--fromUrl='https://cardstack.com/base/'`, - `--toUrl='http://localhost:4205/base/'`, + `--toUrl='${serverIndexUrl}/base/'`, ]); console.log(`realm server database: ${testDBName}`); @@ -302,8 +366,8 @@ export async function startServer({ // Matrix tests don't exercise GitHub PR creation, so disable that route // to avoid pulling Octokit into the realm server startup path. DISABLE_GITHUB_PR_ROUTE: 'true', - PUBLISHED_REALM_BOXEL_SPACE_DOMAIN: 'localhost:4205', - PUBLISHED_REALM_BOXEL_SITE_DOMAIN: 'localhost:4205', + PUBLISHED_REALM_BOXEL_SPACE_DOMAIN: publishedDomain, + PUBLISHED_REALM_BOXEL_SITE_DOMAIN: publishedDomain, }, }); realmServer.unref(); @@ -354,6 +418,7 @@ export async function startServer({ workerManager, testRealmDir, testDBName, + envMode, ); } @@ -394,6 +459,7 @@ export class IsolatedRealmServer implements SQLExecutor { private workerManagerProcess: ReturnType, readonly realmPath: string, // useful for debugging readonly db: string, + private envMode: boolean = false, ) { workerManagerProcess.on('message', (message) => { if (message === 'stopped') { @@ -450,6 +516,11 @@ export class IsolatedRealmServer implements SQLExecutor { } async stop() { + if (this.envMode) { + deregisterServiceFromTraefik(ISOLATED_REALM_SERVICE); + deregisterServiceFromTraefik(ISOLATED_WORKER_SERVICE); + } + let realmServerStop = new Promise( (r) => (this.realmServerStopped = r), ); diff --git a/packages/matrix/playwright.config.ts b/packages/matrix/playwright.config.ts index adb4530187..70ad4290fb 100644 --- a/packages/matrix/playwright.config.ts +++ b/packages/matrix/playwright.config.ts @@ -1,9 +1,16 @@ import { defineConfig, devices } from '@playwright/test'; +import { appURL, realmDomain } from './helpers/isolated-realm-server'; /** * See https://playwright.dev/docs/test-configuration. */ +// In environment mode the isolated realm server is behind Traefik on port 80; +// in standard mode it listens on its own port (default 4205). +let resolverPort = realmDomain.includes(':') + ? realmDomain.split(':').pop()! + : '80'; + export default defineConfig({ testDir: './tests', fullyParallel: true, @@ -14,7 +21,7 @@ export default defineConfig({ reporter: process.env.CI ? 'blob' : 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - baseURL: 'http://localhost:4205/test', + baseURL: appURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'retry-with-trace', @@ -35,7 +42,7 @@ export default defineConfig({ launchOptions: { args: [ // Simulate resolving a custom workspace domain to a realm server - '--host-resolver-rules=MAP published.realm 127.0.0.1:4205', + `--host-resolver-rules=MAP published.realm 127.0.0.1:${resolverPort}`, // Allow iframe to request storage access depsite being considered insecure '--unsafely-treat-insecure-origin-as-secure=http://published.realm', ], diff --git a/packages/matrix/scripts/test.sh b/packages/matrix/scripts/test.sh index 8eeda67de3..58507a39f8 100755 --- a/packages/matrix/scripts/test.sh +++ b/packages/matrix/scripts/test.sh @@ -2,7 +2,9 @@ shard_flag=${1:+--shard} echo "running tests: ${1}" -BASE_REALM="http-get://localhost:4201/base/" +BASE_REALM_HOST="${REALM_BASE_URL:-http://localhost:4201}" +# start-server-and-test needs http-get:// prefix (without the scheme from the URL) +BASE_REALM="http-get://${BASE_REALM_HOST#http://}/base/" READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" diff --git a/packages/matrix/tests/global.setup.ts b/packages/matrix/tests/global.setup.ts index b70ce27f5b..666c045bed 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -11,15 +11,35 @@ import { import type { IsolatedRealmServer } from '../helpers/isolated-realm-server'; import { registerRealmUsers, REGISTRATION_TOKEN } from '../helpers'; import { smtpStart, smtpStop } from '../docker/smtp4dev'; +import { + isEnvironmentMode, + getEnvironmentSlug, + deregisterServiceFromTraefik, + setSynapseURL, +} from '../helpers/environment-config'; + +// Distinct service names so matrix tests don't overwrite dev services +const MATRIX_TEST_SYNAPSE_SERVICE = 'matrix-test'; +const MATRIX_TEST_SMTP_SERVICE = 'smtp-test'; export default async function setup() { - await smtpStart(); - const synapse = await synapseStart(); + await smtpStart({ traefikServiceName: MATRIX_TEST_SMTP_SERVICE }); + const synapse = await synapseStart({ + traefikServiceName: MATRIX_TEST_SYNAPSE_SERVICE, + }); await registerRealmUsers(synapse); let admin = await registerUser(synapse, 'admin', 'adminpass', true); await createRegistrationToken(admin.accessToken, REGISTRATION_TOKEN); + const prerenderServer = await startPrerenderServer(); - const matrixURL = `http://localhost:${synapse.port}`; + // In environment mode the Synapse URL is routed through Traefik + // using a test-specific service name; otherwise use the direct localhost port. + const envMode = isEnvironmentMode(); + const matrixURL = envMode + ? `http://${MATRIX_TEST_SYNAPSE_SERVICE}.${getEnvironmentSlug()}.localhost` + : `http://localhost:${synapse.port}`; + // Override so all Synapse API calls in synapse/index.ts use the test instance + setSynapseURL(matrixURL); let realmServer: IsolatedRealmServer; try { realmServer = await startRealmServer({ @@ -39,6 +59,9 @@ export default async function setup() { }); return async () => { await synapseStop(synapse.synapseId); + if (envMode) { + deregisterServiceFromTraefik(MATRIX_TEST_SYNAPSE_SERVICE); + } await realmServer.stop(); await prerenderServer.stop(); await smtpStop(); diff --git a/packages/matrix/tests/head-tags.spec.ts b/packages/matrix/tests/head-tags.spec.ts index 391e9537aa..c05bc0a1de 100644 --- a/packages/matrix/tests/head-tags.spec.ts +++ b/packages/matrix/tests/head-tags.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures'; import type { Page } from '@playwright/test'; import { randomUUID } from 'crypto'; -import { appURL } from '../helpers/isolated-realm-server'; +import { appURL, realmDomain } from '../helpers/isolated-realm-server'; import { clearLocalStorage, createRealm, @@ -82,7 +82,7 @@ test.describe('Head tags', () => { }) => { await publishDefaultRealm(page); - let publishedRealmURLString = `http://${user.username}.localhost:4205/new-workspace/index`; + let publishedRealmURLString = `http://${user.username}.${realmDomain}/new-workspace/index`; await page.goto(publishedRealmURLString); @@ -270,7 +270,7 @@ test.describe('Head tags', () => { await page.locator('[data-test-publish-button]').click(); await page.waitForSelector('[data-test-unpublish-button]'); - let publishedRealmURL = `http://${user.username}.localhost:4205/${realmName}/`; + let publishedRealmURL = `http://${user.username}.${realmDomain}/${realmName}/`; let defaultCardURL = `${publishedRealmURL}default-head-card.json`; await page.goto(defaultCardURL); diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 00c5316a63..e8311ef304 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -6,7 +6,7 @@ import { postCardSource, waitUntil, } from '../helpers'; -import { appURL } from '../helpers/isolated-realm-server'; +import { appURL, serverIndexUrl, realmDomain } from '../helpers/isolated-realm-server'; import { randomUUID } from 'crypto'; test.describe('Host mode', () => { @@ -185,10 +185,10 @@ test.describe('Host mode', () => { await page.reload(); await page.locator('[data-test-host-mode-isolated]').waitFor(); - publishedRealmURL = `http://published.localhost:4205/${username}/${realmName}/`; + publishedRealmURL = `http://published.${realmDomain}/${username}/${realmName}/`; await page.evaluate( - async ({ realmURL, publishedRealmURL }) => { + async ({ realmURL, publishedRealmURL, realmServerUrl }) => { let sessions = JSON.parse( window.localStorage.getItem('boxel-session') ?? '{}', ); @@ -197,7 +197,7 @@ test.describe('Host mode', () => { throw new Error(`No session token found for ${realmURL}`); } - let response = await fetch('http://localhost:4205/_publish-realm', { + let response = await fetch(`${realmServerUrl}/_publish-realm`, { method: 'POST', headers: { Accept: 'application/json', @@ -216,13 +216,13 @@ test.describe('Host mode', () => { return response.json(); }, - { realmURL, publishedRealmURL }, + { realmURL, publishedRealmURL, realmServerUrl: serverIndexUrl }, ); publishedCardURL = `${publishedRealmURL}index.json`; publishedWhitePaperCardURL = `${publishedRealmURL}white-paper.json`; publishedMyCardURL = `${publishedRealmURL}my-card.json`; - connectRouteURL = `http://localhost:4205/connect/${encodeURIComponent( + connectRouteURL = `${serverIndexUrl}/connect/${encodeURIComponent( publishedRealmURL, )}`; @@ -322,7 +322,7 @@ test.describe('Host mode', () => { page, }) => { let response = await page.goto( - 'http://localhost:4205/connect/http%3A%2F%2Fexample.com', + `${serverIndexUrl}/connect/http%3A%2F%2Fexample.com`, ); expect(response?.status()).toBe(404); diff --git a/packages/matrix/tests/publish-realm.spec.ts b/packages/matrix/tests/publish-realm.spec.ts index 2d5e1eeed7..3e98a7b1ed 100644 --- a/packages/matrix/tests/publish-realm.spec.ts +++ b/packages/matrix/tests/publish-realm.spec.ts @@ -1,6 +1,9 @@ import { test, expect } from './fixtures'; import type { Page } from '@playwright/test'; -import { appURL } from '../helpers/isolated-realm-server'; +import { + serverIndexUrl, + realmDomain, +} from '../helpers/isolated-realm-server'; import { clearLocalStorage, createRealm, @@ -9,8 +12,6 @@ import { postNewCard, } from '../helpers'; -let serverIndexUrl = new URL(appURL).origin; - test.describe('Publish realm', () => { let user: { username: string; password: string; credentials: any }; @@ -60,11 +61,11 @@ test.describe('Publish realm', () => { await newTab.waitForLoadState(); await expect(newTab).toHaveURL( - `http://${user.username}.localhost:4205/new-workspace/`, + `http://${user.username}.${realmDomain}/new-workspace/`, ); await expect( newTab.locator( - `[data-test-card="http://${user.username}.localhost:4205/new-workspace/index"]`, + `[data-test-card="http://${user.username}.${realmDomain}/new-workspace/index"]`, ), ).toBeVisible(); await newTab.close(); @@ -119,11 +120,11 @@ test.describe('Publish realm', () => { await newTab.waitForLoadState(); await expect(newTab).toHaveURL( - 'http://acceptable-subdomain.localhost:4205/', + `http://acceptable-subdomain.${realmDomain}/`, ); await expect( newTab.locator( - '[data-test-card="http://acceptable-subdomain.localhost:4205/index"]', + `[data-test-card="http://acceptable-subdomain.${realmDomain}/index"]`, ), ).toBeVisible(); await newTab.close(); @@ -251,7 +252,7 @@ test.describe('Publish realm', () => { await newTab.waitForLoadState(); await expect(newTab).toHaveURL( - `http://${user.username}.localhost:4205/new-workspace/`, + `http://${user.username}.${realmDomain}/new-workspace/`, ); await newTab.close(); await page.bringToFront(); @@ -281,7 +282,7 @@ test.describe('Publish realm', () => { await newTab.waitForLoadState(); await expect(newTab).toHaveURL( - `http://${user.username}.localhost:4205/new-workspace/`, + `http://${user.username}.${realmDomain}/new-workspace/`, ); await newTab.close(); await page.bringToFront(); diff --git a/packages/matrix/tests/registration-with-token.spec.ts b/packages/matrix/tests/registration-with-token.spec.ts index 498b60faa3..66c4ffb699 100644 --- a/packages/matrix/tests/registration-with-token.spec.ts +++ b/packages/matrix/tests/registration-with-token.spec.ts @@ -5,7 +5,7 @@ import { getAccountData, type SynapseInstance, } from '../docker/synapse'; -import { appURL } from '../helpers/isolated-realm-server'; +import { serverIndexUrl } from '../helpers/isolated-realm-server'; import { validateEmail, gotoRegistration, @@ -23,8 +23,6 @@ import { } from '../helpers'; import { APP_BOXEL_REALMS_EVENT_TYPE } from '../helpers/matrix-constants'; -const serverIndexUrl = new URL(appURL).origin; - function getSynapse(): SynapseInstance { return getMatrixTestContext().synapse; } @@ -253,7 +251,7 @@ test.describe('User Registration w/ Token', () => { APP_BOXEL_REALMS_EVENT_TYPE, ); expect(realms).toEqual({ - realms: [`http://localhost:4205/${firstUser.username}/personal/`], + realms: [`${serverIndexUrl}/${firstUser.username}/personal/`], }); }); diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 629d521d22..f7c008596f 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -15,7 +15,7 @@ import { createSubscribedUserAndLogin, createRealm, } from '../helpers'; -import { appURL } from '../helpers/isolated-realm-server'; +import { appURL, serverIndexUrl } from '../helpers/isolated-realm-server'; import { randomUUID } from 'crypto'; test.describe('Skills', () => { @@ -50,16 +50,14 @@ test.describe('Skills', () => { ).toContainClass('checked'); } - const environmentSkillCardId = `http://localhost:4205/skills/Skill/boxel-environment`; + const environmentSkillCardId = `${serverIndexUrl}/skills/Skill/boxel-environment`; const defaultSkillCardsForCodeMode = [ - `http://localhost:4205/skills/Skill/boxel-development`, + `${serverIndexUrl}/skills/Skill/boxel-development`, environmentSkillCardId, ]; const skillCard1 = `${appURL}/skill-pirate-speak`; const skillCard2 = `${appURL}/skill-seo`; const skillCard3 = `${appURL}/skill-card-title-editing`; - const serverIndexUrl = new URL(appURL).origin; - test(`it can attach skill cards and toggle activation`, async ({ page }) => { await login(page, firstUser.username, firstUser.password, { url: appURL }); await getRoomId(page); diff --git a/packages/realm-server/prerender/prerender-server.ts b/packages/realm-server/prerender/prerender-server.ts index 2e8a762d98..523d2fba01 100644 --- a/packages/realm-server/prerender/prerender-server.ts +++ b/packages/realm-server/prerender/prerender-server.ts @@ -37,7 +37,10 @@ webServerInstance.on('listening', () => { actualPort = (webServerInstance!.address() as import('net').AddressInfo).port ?? port; if (isEnvironmentMode()) { - registerService(webServerInstance!, 'prerender'); + registerService( + webServerInstance!, + process.env.PRERENDER_SERVICE_NAME || 'prerender', + ); } log.info(`prerender server HTTP listening on port ${actualPort}`); });