From fe0ed0c6696042e8017c852db0d3899db034a9f9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Mar 2026 17:57:39 -0700 Subject: [PATCH 01/18] Add preliminary environment mode for Matrix tests --- mise-tasks/lib/env-vars.sh | 24 +++++++ mise-tasks/test-matrix | 20 ++++++ packages/matrix/docker/smtp4dev.ts | 49 ++++++++++--- packages/matrix/helpers/environment-config.ts | 26 +++++-- packages/matrix/helpers/index.ts | 21 ++++-- .../matrix/helpers/isolated-realm-server.ts | 68 +++++++++++++++---- packages/matrix/playwright.config.ts | 11 ++- packages/matrix/scripts/test.sh | 4 +- packages/matrix/tests/global.setup.ts | 12 +++- packages/matrix/tests/head-tags.spec.ts | 6 +- packages/matrix/tests/host-mode.spec.ts | 14 ++-- packages/matrix/tests/publish-realm.spec.ts | 19 +++--- .../tests/registration-with-token.spec.ts | 6 +- packages/matrix/tests/skills.spec.ts | 8 +-- 14 files changed, 219 insertions(+), 69 deletions(-) create mode 100755 mise-tasks/test-matrix diff --git a/mise-tasks/lib/env-vars.sh b/mise-tasks/lib/env-vars.sh index b5738f5b818..637d3d3f9e6 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/test-matrix b/mise-tasks/test-matrix new file mode 100755 index 00000000000..2c27464fd37 --- /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 start-server-and-test \ + 'pnpm run wait' \ + "$BASE_REALM_READY" \ + "pnpm playwright test ${shard_flag} ${1}" diff --git a/packages/matrix/docker/smtp4dev.ts b/packages/matrix/docker/smtp4dev.ts index 2da7690aec6..63b00e4485b 100644 --- a/packages/matrix/docker/smtp4dev.ts +++ b/packages/matrix/docker/smtp4dev.ts @@ -1,10 +1,25 @@ 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; } +function smtpContainerName(): string { + if (isEnvironmentMode()) { + return `boxel-smtp-${getEnvironmentSlug()}`; + } + return 'boxel-smtp'; +} + export async function smtpStart(opts?: Options) { + let containerName = smtpContainerName(); try { await smtpStop(); } catch (e: any) { @@ -12,22 +27,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('smtp', 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('smtp'); + } + await dockerStop({ containerId: containerName }); + await dockerRm({ containerId: containerName }); } diff --git a/packages/matrix/helpers/environment-config.ts b/packages/matrix/helpers/environment-config.ts index dff1ad4f6e5..e843a0c4975 100644 --- a/packages/matrix/helpers/environment-config.ts +++ b/packages/matrix/helpers/environment-config.ts @@ -83,9 +83,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 +113,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 ec87953e035..944fc0c6c3e 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -12,12 +12,18 @@ 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 { 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 = process.env.SMTP_URL || 'http://localhost:5001'; export const initialRoomName = 'New AI Assistant Chat'; export const REGISTRATION_TOKEN = 'abc123'; @@ -108,15 +114,16 @@ async function registerRealmRedirect( } export async function setRealmRedirects(page: Page) { + let baseServerUrl = process.env.REALM_BASE_URL || '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 e4055dd79bf..e3d48fdb72c 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -6,6 +6,11 @@ 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, + registerServiceWithTraefik, + deregisterServiceFromTraefik, +} from './environment-config'; setGracefulCleanup(); @@ -18,7 +23,16 @@ 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'; + +// In environment mode, the isolated realm server is accessed via Traefik. +// The env var is set by mise-tasks/lib/env-vars.sh. +export const serverIndexUrl = + process.env.MATRIX_TEST_REALM_URL || 'http://localhost:4205'; +export const appURL = `${serverIndexUrl}/test`; +export const realmDomain = serverIndexUrl.replace(/^https?:\/\//, ''); const DEFAULT_PRERENDER_PORT = 4231; @@ -219,9 +233,20 @@ export async function startServer({ copySync(testRealmCards, testRealmDir); let testDBName = `test_db_${Math.floor(10000000 * Math.random())}`; - let workerManagerPort = await findAvailablePort(4232); - - process.env.PGPORT = '5435'; + 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; + + 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"; @@ -241,12 +266,12 @@ export async function startServer({ `--prerendererUrl='${prerenderURL}'`, `--migrateDB`, - `--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,7 +292,7 @@ export async function startServer({ let serverArgs = [ `--transpileOnly`, 'main', - `--port=4205`, + `--port=${realmPort}`, `--matrixURL='${matrixURL}'`, `--realmsRootPath='${dir.name}'`, `--workerManagerPort=${workerManagerPort}`, @@ -276,20 +301,20 @@ export async function startServer({ `--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 +327,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(); @@ -349,11 +374,18 @@ export async function startServer({ ); } + // In environment mode, register the isolated realm server and worker with Traefik + if (envMode) { + registerServiceWithTraefik(ISOLATED_REALM_SERVICE, realmPort); + registerServiceWithTraefik(ISOLATED_WORKER_SERVICE, workerManagerPort); + } + return new IsolatedRealmServer( realmServer, workerManager, testRealmDir, testDBName, + envMode, ); } @@ -394,6 +426,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 +483,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 adb45301874..70ad4290fba 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 8eeda67de3a..58507a39f8f 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 b70ce27f5b1..7d49326fd08 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -11,6 +11,11 @@ import { import type { IsolatedRealmServer } from '../helpers/isolated-realm-server'; import { registerRealmUsers, REGISTRATION_TOKEN } from '../helpers'; import { smtpStart, smtpStop } from '../docker/smtp4dev'; +import { + isEnvironmentMode, + getSynapseURL, + deregisterSynapseFromTraefik, +} from '../helpers/environment-config'; export default async function setup() { await smtpStart(); @@ -19,7 +24,11 @@ export default async function setup() { 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; + // otherwise use the direct localhost port. + const matrixURL = isEnvironmentMode() + ? getSynapseURL() + : `http://localhost:${synapse.port}`; let realmServer: IsolatedRealmServer; try { realmServer = await startRealmServer({ @@ -39,6 +48,7 @@ export default async function setup() { }); return async () => { await synapseStop(synapse.synapseId); + deregisterSynapseFromTraefik(); 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 391e9537aab..c05bc0a1de1 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 00c5316a63c..e8311ef304e 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 2d5e1eeed73..3e98a7b1ed3 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 498b60faa3c..66c4ffb6991 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 629d521d222..f7c008596f5 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); From a65ad04c6b31aac1b6a55d5c6b852e1bf2710f66 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Mar 2026 18:01:25 -0700 Subject: [PATCH 02/18] Add prefix for start-server-and-test calls --- mise-tasks/test-matrix | 4 ++-- mise-tasks/test-services-matrix | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mise-tasks/test-matrix b/mise-tasks/test-matrix index 2c27464fd37..7ba01e7e106 100755 --- a/mise-tasks/test-matrix +++ b/mise-tasks/test-matrix @@ -14,7 +14,7 @@ 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 start-server-and-test \ +WAIT_ON_TIMEOUT=600000 pnpm exec start-server-and-test \ 'pnpm run wait' \ "$BASE_REALM_READY" \ - "pnpm playwright test ${shard_flag} ${1}" + "pnpm exec playwright test ${shard_flag} ${1}" diff --git a/mise-tasks/test-services-matrix b/mise-tasks/test-services-matrix index 3f8945eaa97..6533b67a9ea 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' From b00ffc5d2b0342d75828aa26f9ae593686c36afb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 09:00:10 -0700 Subject: [PATCH 03/18] Fix some services in environment mode --- packages/matrix/helpers/index.ts | 12 ++++++++++-- packages/matrix/helpers/isolated-realm-server.ts | 16 +++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index 944fc0c6c3e..18ed753cb27 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -18,12 +18,18 @@ import { 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 { realmDomain, serverIndexUrl }; export const testHost = appURL; -export const mailHost = process.env.SMTP_URL || 'http://localhost:5001'; +export const mailHost = isEnvironmentMode() + ? `http://smtp.${getEnvironmentSlug()}.localhost` + : 'http://localhost:5001'; export const initialRoomName = 'New AI Assistant Chat'; export const REGISTRATION_TOKEN = 'abc123'; @@ -114,7 +120,9 @@ async function registerRealmRedirect( } export async function setRealmRedirects(page: Page) { - let baseServerUrl = process.env.REALM_BASE_URL || 'http://localhost:4201'; + let baseServerUrl = isEnvironmentMode() + ? `http://realm-server.${getEnvironmentSlug()}.localhost` + : 'http://localhost:4201'; await registerRealmRedirect( page, `${baseServerUrl}/skills/`, diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index e3d48fdb72c..c0b6c2c90a5 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -8,6 +8,7 @@ import { createServer as createNetServer, type AddressInfo } from 'net'; import type { SynapseInstance } from '../docker/synapse'; import { isEnvironmentMode, + getEnvironmentSlug, registerServiceWithTraefik, deregisterServiceFromTraefik, } from './environment-config'; @@ -27,10 +28,13 @@ const matrixDir = resolve(join(__dirname, '..')); const ISOLATED_REALM_SERVICE = 'realm-matrix-test'; const ISOLATED_WORKER_SERVICE = 'worker-matrix-test'; -// In environment mode, the isolated realm server is accessed via Traefik. -// The env var is set by mise-tasks/lib/env-vars.sh. -export const serverIndexUrl = - process.env.MATRIX_TEST_REALM_URL || 'http://localhost:4205'; +// 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?:\/\//, ''); @@ -157,7 +161,9 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - BOXEL_HOST_URL: process.env.HOST_URL ?? 'http://localhost:4200', + BOXEL_HOST_URL: envMode + ? `http://host.${envSlug}.localhost` + : (process.env.HOST_URL ?? 'http://localhost:4200'), LOG_LEVELS: process.env.SOFTWARE_FACTORY_PRERENDER_LOG_LEVELS ?? process.env.LOG_LEVELS, }; From 8580d46c5e68b51398c42e1d107d7320ff6568df Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 09:02:29 -0700 Subject: [PATCH 04/18] Add more domain conditionals --- mise-tasks/services/realm-server-base | 16 +++++++++++----- mise-tasks/services/worker-base | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/mise-tasks/services/realm-server-base b/mise-tasks/services/realm-server-base index 63541c4459d..f99b5be6ead 100755 --- a/mise-tasks/services/realm-server-base +++ b/mise-tasks/services/realm-server-base @@ -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 0f6aa184d66..4d180a0f4ca 100755 --- a/mise-tasks/services/worker-base +++ b/mise-tasks/services/worker-base @@ -7,15 +7,15 @@ 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/" From 08243a14ce1b792b8c23c2619db15f3a4cf3d0cc Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 09:13:36 -0700 Subject: [PATCH 05/18] Fix more URLs and ports --- packages/matrix/helpers/isolated-realm-server.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index c0b6c2c90a5..f09fec333fe 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -71,7 +71,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) => { @@ -258,7 +259,9 @@ export async function startServer({ 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.${envSlug}.localhost` + : `http://localhost:${synapse.port}`; process.env.MATRIX_URL = matrixURL; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; process.env.NODE_ENV = 'test'; From fcae21be828024d5cb627fe9afb5acbbf7bb3310 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 09:17:47 -0700 Subject: [PATCH 06/18] Change prerenderer to be isolated --- .../matrix/helpers/isolated-realm-server.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index f09fec333fe..b7d4c542edc 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -27,6 +27,7 @@ const matrixDir = resolve(join(__dirname, '..')); 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. @@ -155,8 +156,9 @@ 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, @@ -211,7 +213,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); @@ -221,10 +223,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); }, }; From dec1d46c80fd49e4fcdce9ec9f4fe842d8cdc90b Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 09:41:36 -0700 Subject: [PATCH 07/18] Fix registration order --- packages/matrix/helpers/isolated-realm-server.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index b7d4c542edc..96767ef92da 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -267,6 +267,13 @@ export async function startServer({ 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 = process.env.PGPORT || '5435'; process.env.PGDATABASE = testDBName; process.env.NODE_NO_WARNINGS = '1'; @@ -397,12 +404,6 @@ export async function startServer({ ); } - // In environment mode, register the isolated realm server and worker with Traefik - if (envMode) { - registerServiceWithTraefik(ISOLATED_REALM_SERVICE, realmPort); - registerServiceWithTraefik(ISOLATED_WORKER_SERVICE, workerManagerPort); - } - return new IsolatedRealmServer( realmServer, workerManager, From 79dcb209f22044221f5960b61d21c73707ad0ca2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 10:21:49 -0700 Subject: [PATCH 08/18] Fix prerender URL in environment mode --- packages/matrix/helpers/isolated-realm-server.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index 96767ef92da..fdc8aea0d1a 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -164,8 +164,11 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', + // Point the prerender at the isolated realm server itself — it proxies + // host app assets via distURL and serves realm content at the correct + // URLs. Standby creation will retry until the realm server is up. BOXEL_HOST_URL: envMode - ? `http://host.${envSlug}.localhost` + ? serverIndexUrl : (process.env.HOST_URL ?? 'http://localhost:4200'), LOG_LEVELS: process.env.SOFTWARE_FACTORY_PRERENDER_LOG_LEVELS ?? process.env.LOG_LEVELS, @@ -326,7 +329,7 @@ export async function startServer({ `--matrixURL='${matrixURL}'`, `--realmsRootPath='${dir.name}'`, `--workerManagerPort=${workerManagerPort}`, - `--prerendererUrl="${prerenderURL}"`, + `--prerendererUrl='${prerenderURL}'`, `--useRegistrationSecretFunction`, `--path='${testRealmDir}'`, From cfde967e932425b7a8be631cad771aa968e23ccb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 10:34:21 -0700 Subject: [PATCH 09/18] Add Traefik-awareness for some startup scripts --- mise-tasks/services/realm-server-base | 2 +- mise-tasks/services/worker-base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mise-tasks/services/realm-server-base b/mise-tasks/services/realm-server-base index f99b5be6ead..b6a80d07274 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 diff --git a/mise-tasks/services/worker-base b/mise-tasks/services/worker-base index 4d180a0f4ca..2523ba74561 100755 --- a/mise-tasks/services/worker-base +++ b/mise-tasks/services/worker-base @@ -1,6 +1,6 @@ #!/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 \ From fdc350434254d368a409511005adcc4842ca045e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 11:33:52 -0700 Subject: [PATCH 10/18] Add paralell infrastructure for Matrix tests --- packages/matrix/docker/smtp4dev.ts | 12 +++++-- packages/matrix/docker/synapse/index.ts | 6 ++-- packages/matrix/helpers/environment-config.ts | 9 ++++++ packages/matrix/helpers/index.ts | 2 +- .../matrix/helpers/isolated-realm-server.ts | 8 ++++- packages/matrix/tests/global.setup.ts | 31 +++++++++++++------ .../prerender/prerender-server.ts | 5 ++- 7 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/matrix/docker/smtp4dev.ts b/packages/matrix/docker/smtp4dev.ts index 63b00e4485b..0e30d7f91fd 100644 --- a/packages/matrix/docker/smtp4dev.ts +++ b/packages/matrix/docker/smtp4dev.ts @@ -9,16 +9,22 @@ import { execSync } from 'child_process'; interface Options { mailClientPort?: number; + traefikServiceName?: string; } +let _smtpServiceName = 'smtp'; + function smtpContainerName(): string { if (isEnvironmentMode()) { - return `boxel-smtp-${getEnvironmentSlug()}`; + 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(); @@ -44,7 +50,7 @@ export async function smtpStart(opts?: Options) { encoding: 'utf-8', }).trim(); let hostPort = parseInt(portOutput.split('\n')[0].split(':').pop()!, 10); - registerServiceWithTraefik('smtp', hostPort); + registerServiceWithTraefik(_smtpServiceName, hostPort); console.log( `Started smtp4dev with id ${containerId} on dynamic port ${hostPort} (Traefik).`, ); @@ -59,7 +65,7 @@ export async function smtpStart(opts?: Options) { export async function smtpStop() { let containerName = smtpContainerName(); if (isEnvironmentMode()) { - deregisterServiceFromTraefik('smtp'); + 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 095de13bb64..3d6fd662c64 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 e843a0c4975..15622c6fcbe 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'; } diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index 18ed753cb27..eedca174bf3 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -28,7 +28,7 @@ import { randomUUID } from 'crypto'; export { realmDomain, serverIndexUrl }; export const testHost = appURL; export const mailHost = isEnvironmentMode() - ? `http://smtp.${getEnvironmentSlug()}.localhost` + ? `http://smtp-test.${getEnvironmentSlug()}.localhost` : 'http://localhost:5001'; export const initialRoomName = 'New AI Assistant Chat'; export const REGISTRATION_TOKEN = 'abc123'; diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index fdc8aea0d1a..8f25a523abb 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -170,6 +170,8 @@ export async function startPrerenderServer( 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, }; @@ -284,7 +286,7 @@ export async function startServer({ process.env.REALM_SECRET_SEED = "shhh! it's a secret"; process.env.GRAFANA_SECRET = "shhh! it's a secret"; let matrixURL = envMode - ? `http://matrix.${envSlug}.localhost` + ? `http://matrix-test.${envSlug}.localhost` : `http://localhost:${synapse.port}`; process.env.MATRIX_URL = matrixURL; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; @@ -298,6 +300,8 @@ 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='${serverIndexUrl}/test/'`, `--toUrl='${serverIndexUrl}/test/'`, @@ -331,6 +335,8 @@ export async function startServer({ `--workerManagerPort=${workerManagerPort}`, `--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'`, diff --git a/packages/matrix/tests/global.setup.ts b/packages/matrix/tests/global.setup.ts index 7d49326fd08..666c045bed2 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -13,22 +13,33 @@ import { registerRealmUsers, REGISTRATION_TOKEN } from '../helpers'; import { smtpStart, smtpStop } from '../docker/smtp4dev'; import { isEnvironmentMode, - getSynapseURL, - deregisterSynapseFromTraefik, + 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(); - // In environment mode the Synapse URL is routed through Traefik; - // otherwise use the direct localhost port. - const matrixURL = isEnvironmentMode() - ? getSynapseURL() + // 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({ @@ -48,7 +59,9 @@ export default async function setup() { }); return async () => { await synapseStop(synapse.synapseId); - deregisterSynapseFromTraefik(); + if (envMode) { + deregisterServiceFromTraefik(MATRIX_TEST_SYNAPSE_SERVICE); + } await realmServer.stop(); await prerenderServer.stop(); await smtpStop(); diff --git a/packages/realm-server/prerender/prerender-server.ts b/packages/realm-server/prerender/prerender-server.ts index 2e8a762d982..523d2fba014 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}`); }); From 62e2909762a02b926ef67dc1fdb211673060a436 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Mar 2026 13:29:14 -0700 Subject: [PATCH 11/18] Change prerender server in environment mode --- packages/matrix/helpers/isolated-realm-server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index 8f25a523abb..e0705132e67 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -164,11 +164,11 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - // Point the prerender at the isolated realm server itself — it proxies - // host app assets via distURL and serves realm content at the correct - // URLs. Standby creation will retry until the realm server is up. + // 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 - ? serverIndexUrl + ? `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, From 068fdd2103d4acb02c85aa6735cea35e01bd8664 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 25 Mar 2026 17:21:35 -0700 Subject: [PATCH 12/18] Add more environment mode fixes --- packages/matrix/docker/synapse/index.ts | 4 + packages/matrix/helpers/index.ts | 33 +++++++++ .../matrix/helpers/isolated-realm-server.ts | 9 ++- packages/matrix/tests/global.setup.ts | 73 ++++++++++++++++--- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index 3d6fd662c64..b0fe6f1b70f 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -135,6 +135,10 @@ export async function synapseStart( (isEnvironmentMode() ? getSynapseContainerName() : path.basename(synCfg.configDir)); + + // Always clean up the target container in case a previous run was interrupted + await dockerStop({ containerId: containerName }).catch(() => {}); + console.log( `Starting synapse with config dir ${synCfg.configDir} in container ${containerName}...`, ); diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index eedca174bf3..e2d22efc14d 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -119,6 +119,17 @@ 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` @@ -133,6 +144,28 @@ export async function setRealmRedirects(page: Page) { `${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 e0705132e67..57bfc463a4b 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -164,11 +164,12 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - // 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. + // Point the prerender at the dev realm server for standby page creation. + // The dev realm server serves the host app shell at /standby and has + // the base realm indexed — avoiding the deadlock of pointing at the + // isolated realm server (which needs the prerender to finish indexing). BOXEL_HOST_URL: envMode - ? `http://host.${envSlug}.localhost` + ? `http://realm-server.${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, diff --git a/packages/matrix/tests/global.setup.ts b/packages/matrix/tests/global.setup.ts index 666c045bed2..ee040429966 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -18,28 +18,77 @@ import { 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() { + const envMode = isEnvironmentMode(); 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 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, + ); - const prerenderServer = await startPrerenderServer(); - // 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(); + // 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}`; - // Override so all Synapse API calls in synapse/index.ts use the test instance 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(); let realmServer: IsolatedRealmServer; try { realmServer = await startRealmServer({ From 460b438331f42ce90715d8e533310e1760e5a2bd Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 26 Mar 2026 09:50:35 -0700 Subject: [PATCH 13/18] Fix more setup --- mise-tasks/services/realm-server-base | 19 +++++++++++++++---- mise-tasks/services/worker-base | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/mise-tasks/services/realm-server-base b/mise-tasks/services/realm-server-base index b6a80d07274..25f1601ea5c 100755 --- a/mise-tasks/services/realm-server-base +++ b/mise-tasks/services/realm-server-base @@ -8,16 +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="${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" \ @@ -25,9 +36,9 @@ NODE_ENV=development \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ts-node \ --transpileOnly main \ - --port="${REALM_PORT:-4201}" \ + --port="${REALM_BASE_PORT}" \ --matrixURL="${MATRIX_URL_VAL}" \ - --realmsRootPath="${REALMS_ROOT:-./realms/localhost_4201_base}" \ + --realmsRootPath="${REALM_BASE_ROOT}" \ --prerendererUrl="${PRERENDER_URL}" \ --migrateDB \ $WORKER_MANAGER_ARG \ @@ -35,4 +46,4 @@ NODE_ENV=development \ --path='../base' \ --username='base_realm' \ --fromUrl='https://cardstack.com/base/' \ - --toUrl="${REALM_BASE_URL:-http://localhost:4201}/base/" + --toUrl="${REALM_BASE_TO_URL}" diff --git a/mise-tasks/services/worker-base b/mise-tasks/services/worker-base index 2523ba74561..97706753192 100755 --- a/mise-tasks/services/worker-base +++ b/mise-tasks/services/worker-base @@ -3,17 +3,28 @@ #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="${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="${WORKER_PORT:-4213}" \ + --port="${WORKER_BASE_PORT}" \ --matrixURL="${MATRIX_URL_VAL}" \ --prerendererUrl="${PRERENDER_MGR_URL}" \ \ From 7edddb6a8695c4a2ddbb6108642b70c38473e36e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 26 Mar 2026 13:19:02 -0700 Subject: [PATCH 14/18] Change isolated realm server resolution --- packages/matrix/helpers/isolated-realm-server.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index 7fdfc3143c5..b1a0188dd38 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -163,12 +163,11 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - // Point the prerender at the dev realm server for standby page creation. - // The dev realm server serves the host app shell at /standby and has - // the base realm indexed — avoiding the deadlock of pointing at the - // isolated realm server (which needs the prerender to finish indexing). + // 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 - ? `http://realm-server.${envSlug}.localhost` + ? 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, From f6758deac081486680ad517a91633f7bb14727e9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 26 Mar 2026 13:20:09 -0700 Subject: [PATCH 15/18] Add missing PATH setup --- mise-tasks/dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mise-tasks/dev b/mise-tasks/dev index 348000c492b..069cedaac73 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 \ From 8c5dba98906323df5bcf71f9f6e0afbba5611882 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 26 Mar 2026 15:55:35 -0700 Subject: [PATCH 16/18] Add pooling maximum for isolated realm server --- packages/matrix/helpers/isolated-realm-server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index b1a0188dd38..b9f78bd0c37 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -287,6 +287,9 @@ export async function startServer({ 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 = [ From bdcdcf339f2b2cff84743af85442229d888dd5cd Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 1 Apr 2026 09:16:37 -0700 Subject: [PATCH 17/18] matrix: Environment-mode support for Matrix Playwright tests - getSynapseURL falls back to MATRIX_TEST_CONTEXT env var so Playwright worker processes use the test Synapse (not the dev Synapse) - Test Synapse temporarily overwrites the dev 'matrix' Traefik route (required because Playwright page.route cannot intercept WebSockets); teardown restores the dev route - Test Synapse uses a separate Docker container (boxel-synapse-test-*) so the dev Synapse container keeps running - global.setup waits for host app availability before starting realm server - page.route rewrites Ember config matrixURL and redirects Matrix API calls - setRealmRedirects redirects base/skills realm URLs to isolated test server - Prerender BOXEL_HOST_URL points to isolated realm server (same-origin, no CORS issues); standby creation retries after realm server boots - PG_POOL_MAX defaults to 5 for test processes to avoid exhausting Postgres connections when running alongside the dev stack Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/matrix/helpers/environment-config.ts | 13 ++++++ packages/matrix/helpers/index.ts | 40 ++++++++++++------- .../matrix/helpers/isolated-realm-server.ts | 12 ++++-- packages/matrix/tests/global.setup.ts | 33 +++++++++++++-- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/matrix/helpers/environment-config.ts b/packages/matrix/helpers/environment-config.ts index a2fcdf2b73f..b274979b2a3 100644 --- a/packages/matrix/helpers/environment-config.ts +++ b/packages/matrix/helpers/environment-config.ts @@ -73,6 +73,19 @@ export function getSynapseURL(synapse?: { if (_synapseURLOverride) { return _synapseURLOverride; } + // In Playwright worker processes, _synapseURLOverride isn't set (it was set + // in the global.setup process). Fall back to MATRIX_TEST_CONTEXT which IS + // shared via env var. + if (process.env.MATRIX_TEST_CONTEXT) { + try { + let ctx = JSON.parse(process.env.MATRIX_TEST_CONTEXT); + if (ctx.matrixUrl) { + return ctx.matrixUrl; + } + } catch { + // ignore parse errors + } + } if (synapse?.baseUrl) { return synapse.baseUrl; } diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index e2d22efc14d..b45bfe52e87 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -146,25 +146,35 @@ export async function setRealmRedirects(page: Page) { ); // 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). + // to the test Synapse. We intercept HTML document responses at the network + // level so the rewrite happens before the browser parses the HTML. + // The meta tag content is percent-encoded, so we replace the encoded form. + // Also redirect Matrix API calls from the dev Synapse to the test Synapse. 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 }, - ); + let devMatrixEncoded = encodeURIComponent(devMatrixUrl); + let testMatrixEncoded = encodeURIComponent(testMatrixUrl); + + // Rewrite matrixURL in HTML document responses + await page.route('**/test*', async (route) => { + if (route.request().resourceType() === 'document') { + let response = await route.fetch(); + let body = await response.text(); + body = body.split(devMatrixEncoded).join(testMatrixEncoded); + await route.fulfill({ response, body }); + } else { + await route.continue(); + } + }); + + // Redirect all Matrix API/WebSocket calls to the test Synapse + await page.route(`${devMatrixUrl}/**`, async (route) => { + let url = route.request().url(); + let newUrl = url.replace(devMatrixUrl, testMatrixUrl); + await route.continue({ url: newUrl }); + }); } } diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index b9f78bd0c37..51ad8d3a890 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -163,9 +163,11 @@ export async function startPrerenderServer( ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'development', NODE_NO_WARNINGS: '1', - // 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. + // Point the prerender at the isolated realm server itself. It proxies + // the host app's index.html and serves realm content on the same origin, + // avoiding CORS issues between host.*.localhost and realm-server.*.localhost. + // Standby creation will fail during initial boot but succeeds once indexing + // completes (the prerender retries automatically). BOXEL_HOST_URL: envMode ? serverIndexUrl : (process.env.HOST_URL ?? 'http://localhost:4200'), @@ -281,8 +283,10 @@ export async function startServer({ 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"; + // Use 'matrix' (not 'matrix-test') because the test Synapse overwrites the + // dev Synapse's Traefik route — see global.setup.ts for why. let matrixURL = envMode - ? `http://matrix-test.${envSlug}.localhost` + ? `http://matrix.${envSlug}.localhost` : `http://localhost:${synapse.port}`; process.env.MATRIX_URL = matrixURL; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; diff --git a/packages/matrix/tests/global.setup.ts b/packages/matrix/tests/global.setup.ts index ee040429966..58818bc7106 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -14,15 +14,38 @@ import { smtpStart, smtpStop } from '../docker/smtp4dev'; import { isEnvironmentMode, getEnvironmentSlug, - deregisterServiceFromTraefik, + getSynapseContainerName, + registerServiceWithTraefik, setSynapseURL, } from '../helpers/environment-config'; -const MATRIX_TEST_SYNAPSE_SERVICE = 'matrix-test'; +// The test Synapse MUST overwrite the dev 'matrix' Traefik route because +// Playwright's page.route cannot intercept WebSocket connections. The Ember +// app's Matrix client uses WebSockets, so it must connect to the test Synapse +// at the same hostname the Ember config expects (matrix.*.localhost). +// The dev Synapse container stays running; only the Traefik route is swapped. +// Teardown restores the dev route. +const MATRIX_TEST_SYNAPSE_SERVICE = 'matrix'; const MATRIX_TEST_SMTP_SERVICE = 'smtp-test'; export default async function setup() { const envMode = isEnvironmentMode(); + + // Save the dev Synapse's port so we can restore the Traefik route in teardown + let devSynapsePort: number | undefined; + if (envMode) { + try { + let { execSync } = await import('child_process'); + let output = execSync( + `docker port ${getSynapseContainerName()} 8008/tcp`, + { encoding: 'utf-8' }, + ).trim(); + devSynapsePort = parseInt(output.split('\n')[0].split(':').pop()!, 10); + } catch { + // Dev Synapse not running — nothing to restore + } + } + await smtpStart({ traefikServiceName: MATRIX_TEST_SMTP_SERVICE }); const synapse = await synapseStart( { @@ -108,8 +131,10 @@ export default async function setup() { }); return async () => { await synapseStop(synapse.synapseId); - if (envMode) { - deregisterServiceFromTraefik(MATRIX_TEST_SYNAPSE_SERVICE); + // Restore the dev Synapse's Traefik route (the test Synapse overwrote it) + if (envMode && devSynapsePort) { + registerServiceWithTraefik('matrix', devSynapsePort); + console.log(`Restored dev Synapse route (port ${devSynapsePort})`); } await realmServer.stop(); await prerenderServer.stop(); From 91275af74fc9cadafa273c87ad21d71a06080ed7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 1 Apr 2026 09:35:13 -0700 Subject: [PATCH 18/18] matrix: Use separate test Synapse domain via realm server config rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realm server's retrieveIndexHTML() already rewrites the Ember config's matrixURL to match its --matrixURL flag. This means the test Synapse can use a distinct service name (matrix-test) without colliding with the dev Synapse — the browser gets the correct URL from the realm server's HTML. Removes the page.route HTML/API redirect hack and the dev Synapse route save/restore logic. Also reads matrixUrl from MATRIX_TEST_CONTEXT env var in getSynapseURL() so Playwright worker processes use the test Synapse. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/matrix/helpers/index.ts | 34 ++--------------- .../matrix/helpers/isolated-realm-server.ts | 4 +- packages/matrix/tests/global.setup.ts | 38 +++++-------------- 3 files changed, 13 insertions(+), 63 deletions(-) diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index b45bfe52e87..5d47b1ae36c 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -145,37 +145,9 @@ export async function setRealmRedirects(page: Page) { `${serverIndexUrl}/base/`, ); - // In env mode, rewrite the Ember app's baked-in matrixURL config to point - // to the test Synapse. We intercept HTML document responses at the network - // level so the rewrite happens before the browser parses the HTML. - // The meta tag content is percent-encoded, so we replace the encoded form. - // Also redirect Matrix API calls from the dev Synapse to the test Synapse. - if (isEnvironmentMode()) { - let slug = getEnvironmentSlug(); - let devMatrixUrl = `http://matrix.${slug}.localhost`; - let testMatrixUrl = `http://matrix-test.${slug}.localhost`; - let devMatrixEncoded = encodeURIComponent(devMatrixUrl); - let testMatrixEncoded = encodeURIComponent(testMatrixUrl); - - // Rewrite matrixURL in HTML document responses - await page.route('**/test*', async (route) => { - if (route.request().resourceType() === 'document') { - let response = await route.fetch(); - let body = await response.text(); - body = body.split(devMatrixEncoded).join(testMatrixEncoded); - await route.fulfill({ response, body }); - } else { - await route.continue(); - } - }); - - // Redirect all Matrix API/WebSocket calls to the test Synapse - await page.route(`${devMatrixUrl}/**`, async (route) => { - let url = route.request().url(); - let newUrl = url.replace(devMatrixUrl, testMatrixUrl); - await route.continue({ url: newUrl }); - }); - } + // No Matrix URL rewrite needed here — the isolated realm server's + // retrieveIndexHTML() rewrites the Ember config's matrixURL to the + // test Synapse URL (from --matrixURL) when serving index.html. } 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 51ad8d3a890..5af5d9df672 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -283,10 +283,8 @@ export async function startServer({ 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"; - // Use 'matrix' (not 'matrix-test') because the test Synapse overwrites the - // dev Synapse's Traefik route — see global.setup.ts for why. let matrixURL = envMode - ? `http://matrix.${envSlug}.localhost` + ? `http://matrix-test.${envSlug}.localhost` : `http://localhost:${synapse.port}`; process.env.MATRIX_URL = matrixURL; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; diff --git a/packages/matrix/tests/global.setup.ts b/packages/matrix/tests/global.setup.ts index 58818bc7106..b1ffde29864 100644 --- a/packages/matrix/tests/global.setup.ts +++ b/packages/matrix/tests/global.setup.ts @@ -14,38 +14,20 @@ import { smtpStart, smtpStop } from '../docker/smtp4dev'; import { isEnvironmentMode, getEnvironmentSlug, - getSynapseContainerName, - registerServiceWithTraefik, + deregisterServiceFromTraefik, setSynapseURL, } from '../helpers/environment-config'; -// The test Synapse MUST overwrite the dev 'matrix' Traefik route because -// Playwright's page.route cannot intercept WebSocket connections. The Ember -// app's Matrix client uses WebSockets, so it must connect to the test Synapse -// at the same hostname the Ember config expects (matrix.*.localhost). -// The dev Synapse container stays running; only the Traefik route is swapped. -// Teardown restores the dev route. -const MATRIX_TEST_SYNAPSE_SERVICE = 'matrix'; +// Separate service names so test infrastructure doesn't collide with the dev +// stack. The isolated realm server rewrites the Ember config's matrixURL in +// its index.html response (see server.ts retrieveIndexHTML), so the browser +// connects to matrix-test.*.localhost automatically. +const MATRIX_TEST_SYNAPSE_SERVICE = 'matrix-test'; const MATRIX_TEST_SMTP_SERVICE = 'smtp-test'; export default async function setup() { const envMode = isEnvironmentMode(); - // Save the dev Synapse's port so we can restore the Traefik route in teardown - let devSynapsePort: number | undefined; - if (envMode) { - try { - let { execSync } = await import('child_process'); - let output = execSync( - `docker port ${getSynapseContainerName()} 8008/tcp`, - { encoding: 'utf-8' }, - ).trim(); - devSynapsePort = parseInt(output.split('\n')[0].split(':').pop()!, 10); - } catch { - // Dev Synapse not running — nothing to restore - } - } - await smtpStart({ traefikServiceName: MATRIX_TEST_SMTP_SERVICE }); const synapse = await synapseStart( { @@ -67,7 +49,7 @@ export default async function setup() { : `http://localhost:${synapse.port}`; setSynapseURL(matrixURL); - // Wait for the test Synapse to be reachable through Traefik before registering users + // Wait for the test Synapse to be reachable through Traefik if (envMode) { let start = Date.now(); while (Date.now() - start < 30_000) { @@ -131,10 +113,8 @@ export default async function setup() { }); return async () => { await synapseStop(synapse.synapseId); - // Restore the dev Synapse's Traefik route (the test Synapse overwrote it) - if (envMode && devSynapsePort) { - registerServiceWithTraefik('matrix', devSynapsePort); - console.log(`Restored dev Synapse route (port ${devSynapsePort})`); + if (envMode) { + deregisterServiceFromTraefik(MATRIX_TEST_SYNAPSE_SERVICE); } await realmServer.stop(); await prerenderServer.stop();