diff --git a/mise-tasks/dev b/mise-tasks/dev index 348000c492..069cedaac7 100755 --- a/mise-tasks/dev +++ b/mise-tasks/dev @@ -2,6 +2,9 @@ #MISE description="Start full dev stack (realm server, workers, test realms)" #MISE dir="packages/realm-server" +# Add node_modules/.bin to PATH (mise run bypasses pnpm which normally does this) +PATH="$(pwd)/../../node_modules/.bin:$(pwd)/node_modules/.bin:$PATH" + . "$(cd "$(dirname "$0")" && pwd)/lib/dev-common.sh" WAIT_ON_TIMEOUT=2400000 NODE_NO_WARNINGS=1 start-server-and-test \ 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..25f1601ea5 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,27 @@ if [ -z "$MATRIX_REGISTRATION_SHARED_SECRET" ]; then export MATRIX_REGISTRATION_SHARED_SECRET fi +# The base-only realm server uses dedicated port/db/paths that differ from +# the main development realm server. In env mode, use the env vars; in +# standard mode, use the base-specific defaults. +if [ -n "$ENV_MODE" ]; then + WORKER_MANAGER_ARG="--workerManagerUrl=${WORKER_MGR_URL}" + REALM_BASE_PORT="${REALM_PORT}" + REALM_BASE_DB="${PGDATABASE}" + REALM_BASE_ROOT="${REALMS_ROOT}" + REALM_BASE_TO_URL="${REALM_BASE_URL}/base/" +else + WORKER_MANAGER_ARG="$1" + REALM_BASE_PORT=4201 + REALM_BASE_DB=boxel_base + REALM_BASE_ROOT="./realms/localhost_4201_base" + REALM_BASE_TO_URL="http://localhost:4201/base/" +fi + NODE_ENV=development \ NODE_NO_WARNINGS=1 \ PGPORT="${PGPORT}" \ - PGDATABASE=boxel_base \ + PGDATABASE="${REALM_BASE_DB}" \ 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 +36,14 @@ NODE_ENV=development \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ts-node \ --transpileOnly main \ - --port=4201 \ + --port="${REALM_BASE_PORT}" \ --matrixURL="${MATRIX_URL_VAL}" \ - --realmsRootPath='./realms/localhost_4201_base' \ + --realmsRootPath="${REALM_BASE_ROOT}" \ --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_TO_URL}" diff --git a/mise-tasks/services/worker-base b/mise-tasks/services/worker-base index 0f6aa184d6..9770675319 100755 --- a/mise-tasks/services/worker-base +++ b/mise-tasks/services/worker-base @@ -1,21 +1,32 @@ #!/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" +# The base-only worker uses dedicated port/db that differ from the main +# development worker (WORKER_PORT/PGDATABASE). In env mode, use the env +# vars; in standard mode, use the base-specific defaults. +if [ -n "$ENV_MODE" ]; then + WORKER_BASE_PORT="${WORKER_PORT}" + WORKER_BASE_DB="${PGDATABASE}" +else + WORKER_BASE_PORT=4213 + WORKER_BASE_DB=boxel_base +fi + NODE_ENV=development \ NODE_NO_WARNINGS=1 \ NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}" \ PGPORT="${PGPORT}" \ - PGDATABASE=boxel_base \ + PGDATABASE="${WORKER_BASE_DB}" \ 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_BASE_PORT}" \ --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/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 eebd8c9043..de7b5c5901 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -17,7 +17,7 @@ import { isEnvironmentMode, getSynapseContainerName, getSynapseURL, - registerSynapseWithTraefik, + registerServiceWithTraefik, } from '../../helpers/environment-config'; export const SYNAPSE_IP_ADDRESS = '172.20.0.5'; @@ -157,6 +157,7 @@ interface StartOptions { dataDir?: string; containerName?: string; suppressRegistrationSecretFile?: true; + traefikServiceName?: string; dynamicHostPort?: true; } @@ -230,6 +231,9 @@ export async function synapseStart( ); } + // Clean up stale container from a previous interrupted run + await dockerStop({ containerId: containerName }).catch(() => {}); + try { synapseId = await dockerRun({ image: 'matrixdotorg/synapse:v1.126.0', @@ -286,7 +290,8 @@ export async function synapseStart( } if (isEnvironmentMode()) { - registerSynapseWithTraefik(hostPort); + let synapseServiceName = opts?.traefikServiceName || 'matrix'; + registerServiceWithTraefik(synapseServiceName, hostPort); } const synapse: SynapseInstance = { diff --git a/packages/matrix/helpers/environment-config.ts b/packages/matrix/helpers/environment-config.ts index d3ebf7682d..a2fcdf2b73 100644 --- a/packages/matrix/helpers/environment-config.ts +++ b/packages/matrix/helpers/environment-config.ts @@ -60,10 +60,19 @@ export function getSynapseContainerName(): string { return 'boxel-synapse'; } +let _synapseURLOverride: string | undefined; + +export function setSynapseURL(url: string): void { + _synapseURLOverride = url; +} + export function getSynapseURL(synapse?: { baseUrl?: string; port?: number; }): string { + if (_synapseURLOverride) { + return _synapseURLOverride; + } if (synapse?.baseUrl) { return synapse.baseUrl; } @@ -88,9 +97,11 @@ export function getSynapseURL(synapse?: { } } -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}`; @@ -115,27 +126,39 @@ export function registerSynapseWithTraefik(hostPort: number): void { }; atomicWrite(configPath, yaml.stringify(config)); - console.log(`Registered Synapse at ${hostname} -> localhost:${hostPort}`); + console.log( + `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..e2d22efc14 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'; @@ -107,17 +119,53 @@ async function registerRealmRedirect( }); } +// In env mode, the test Synapse runs under a separate service name +// (matrix-test) so it doesn't disrupt the dev Synapse. The Ember app's +// baked-in config points to the dev Matrix URL, so we redirect those +// calls to the test Synapse via Playwright page routes. +export function getTestMatrixUrl(): string | undefined { + if (!isEnvironmentMode()) { + return undefined; + } + return `http://matrix-test.${getEnvironmentSlug()}.localhost`; +} + 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/`, ); + + // In env mode, rewrite the Ember app's baked-in matrixURL config to point + // to the test Synapse. This must happen before the app boots, and covers + // all connection types (fetch, WebSocket). + if (isEnvironmentMode()) { + let slug = getEnvironmentSlug(); + let devMatrixUrl = `http://matrix.${slug}.localhost`; + let testMatrixUrl = `http://matrix-test.${slug}.localhost`; + await page.context().addInitScript( + ({ devUrl, testUrl }) => { + let meta = document.querySelector( + 'meta[name="@cardstack/host/config/environment"]', + ); + if (meta) { + let content = decodeURIComponent(meta.getAttribute('content') || ''); + content = content.split(devUrl).join(testUrl); + meta.setAttribute('content', encodeURIComponent(content)); + } + }, + { devUrl: devMatrixUrl, testUrl: testMatrixUrl }, + ); + } } export async function registerRealmUsers(synapse: SynapseInstance) { diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index c9263e838b..b9f78bd0c3 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,13 +156,21 @@ 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 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 isolated realm server. Initial standby + // creation will fail while the realm server is booting/indexing, but + // the prerender retries and on-demand page creation works once ready. + BOXEL_HOST_URL: envMode + ? serverIndexUrl + : (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, }; @@ -186,7 +214,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); @@ -196,10 +224,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); }, }; @@ -215,18 +255,41 @@ 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'; + // Limit connection pool to avoid exhausting Postgres max_connections + // when running alongside the dev stack + process.env.PG_POOL_MAX = process.env.PG_POOL_MAX || '5'; process.env.LOW_CREDIT_THRESHOLD = '2000'; let workerArgs = [ @@ -236,13 +299,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, { @@ -263,29 +328,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}`); @@ -298,8 +365,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(); @@ -350,6 +417,7 @@ export async function startServer({ workerManager, testRealmDir, testDBName, + envMode, ); } @@ -390,6 +458,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') { @@ -446,6 +515,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..ee04042996 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -11,15 +11,84 @@ 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'; + +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(); + const envMode = isEnvironmentMode(); + await smtpStart({ traefikServiceName: MATRIX_TEST_SMTP_SERVICE }); + const synapse = await synapseStart( + { + traefikServiceName: MATRIX_TEST_SYNAPSE_SERVICE, + // Use a separate container so the dev Synapse keeps running + ...(envMode + ? { containerName: `boxel-synapse-test-${getEnvironmentSlug()}` } + : {}), + }, + // In env mode, don't stop the dev Synapse + !envMode, + ); + + // In env mode, override getSynapseURL() BEFORE registering users so all + // Synapse API calls (registerUser, createRegistrationToken, loginUser) + // hit the test Synapse, not the dev Synapse. + const matrixURL = envMode + ? `http://${MATRIX_TEST_SYNAPSE_SERVICE}.${getEnvironmentSlug()}.localhost` + : `http://localhost:${synapse.port}`; + setSynapseURL(matrixURL); + + // Wait for the test Synapse to be reachable through Traefik before registering users + if (envMode) { + let start = Date.now(); + while (Date.now() - start < 30_000) { + try { + let res = await fetch(`${matrixURL}/health`); + if (res.ok) break; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + } + await registerRealmUsers(synapse); let admin = await registerUser(synapse, 'admin', 'adminpass', true); await createRegistrationToken(admin.accessToken, REGISTRATION_TOKEN); + + // In env mode, wait for the host app before starting the realm server — + // the realm server fetches index.html from distURL on boot and exits if + // it's not available. + if (envMode) { + let hostUrl = `http://host.${getEnvironmentSlug()}.localhost`; + let start = Date.now(); + let ready = false; + while (Date.now() - start < 60_000) { + try { + let res = await fetch(hostUrl); + if (res.ok) { + ready = true; + break; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + if (!ready) { + throw new Error( + `Host app at ${hostUrl} not available after 60s. Is the dev stack running? (BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT} mise run dev-all)`, + ); + } + } + const prerenderServer = await startPrerenderServer(); - const matrixURL = `http://localhost:${synapse.port}`; let realmServer: IsolatedRealmServer; try { realmServer = await startRealmServer({ @@ -39,6 +108,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 690ee9b3d6..490ba0cf95 100644 --- a/packages/realm-server/prerender/prerender-server.ts +++ b/packages/realm-server/prerender/prerender-server.ts @@ -31,7 +31,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}`); });