diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 900b91b4e0..720649f965 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -419,61 +419,67 @@ jobs: -d '{"context":"Matrix Playwright tests report","description":"'"$description"'","target_url":"'"$PLAYWRIGHT_REPORT_URL"'","state":"'"$state"'"}' realm-server-test: - name: Realm Server Tests + name: Realm Server Tests (shard ${{ matrix.shard }}) needs: [change-check, test-web-assets] if: needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: - group: realm-server-test-${{ matrix.testModule }}-${{ github.head_ref || github.run_id }} + group: realm-server-test-${{ matrix.shard }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true strategy: fail-fast: false matrix: - testModule: - [ - "auth-client-test.ts", - "billing-test.ts", - "card-dependencies-endpoint-test.ts", - "card-endpoints-test.ts", - "card-source-endpoints-test.ts", - "definition-lookup-test.ts", - "file-watcher-events-test.ts", - "indexing-test.ts", - "transpile-test.ts", - "module-syntax-test.ts", - "permissions/permission-checker-test.ts", - "prerendering-test.ts", - "prerender-server-test.ts", - "prerender-manager-test.ts", - "prerender-proxy-test.ts", - "remote-prerenderer-test.ts", - "queue-test.ts", - "realm-endpoints/dependencies-test.ts", - "realm-endpoints/directory-test.ts", - "realm-endpoints/info-test.ts", - "realm-endpoints/lint-test.ts", - "realm-endpoints/mtimes-test.ts", - "realm-endpoints/permissions-test.ts", - "realm-endpoints/publishability-test.ts", - "realm-endpoints/search-test.ts", - "realm-endpoints/user-test.ts", - "realm-endpoints-test.ts", - "search-prerendered-test.ts", - "types-endpoint-test.ts", - "server-endpoints-test.ts", - "server-endpoints/search-test.ts", - "server-endpoints/search-prerendered-test.ts", - "virtual-network-test.ts", - "atomic-endpoints-test.ts", - "request-forward-test.ts", - "publish-unpublish-realm-test.ts", - "boxel-domain-availability-test.ts", - "claim-boxel-domain-test.ts", - "delete-boxel-claimed-domain-test.ts", - "get-boxel-claimed-domain-test.ts", - "realm-auth-test.ts", - "queries-test.ts", - ] + include: + - shard: 1 + testModules: + - server-endpoints-test.ts + - atomic-endpoints-test.ts + - request-forward-test.ts + - realm-endpoints/info-test.ts + - realm-endpoints/user-test.ts + - file-watcher-events-test.ts + - types-endpoint-test.ts + - prerender-manager-test.ts + - prerender-proxy-test.ts + - transpile-test.ts + - server-endpoints/search-prerendered-test.ts + - shard: 2 + testModules: + - card-endpoints-test.ts + - publish-unpublish-realm-test.ts + - realm-endpoints/lint-test.ts + - claim-boxel-domain-test.ts + - realm-endpoints/publishability-test.ts + - boxel-domain-availability-test.ts + - realm-endpoints/dependencies-test.ts + - queue-test.ts + - remote-prerenderer-test.ts + - permissions/permission-checker-test.ts + - shard: 3 + testModules: + - realm-endpoints-test.ts + - prerendering-test.ts + - search-prerendered-test.ts + - card-dependencies-endpoint-test.ts + - delete-boxel-claimed-domain-test.ts + - get-boxel-claimed-domain-test.ts + - realm-endpoints/mtimes-test.ts + - billing-test.ts + - virtual-network-test.ts + - module-syntax-test.ts + - shard: 4 + testModules: + - indexing-test.ts + - card-source-endpoints-test.ts + - realm-endpoints/search-test.ts + - server-endpoints/search-test.ts + - realm-endpoints/permissions-test.ts + - realm-endpoints/directory-test.ts + - definition-lookup-test.ts + - prerender-server-test.ts + - realm-auth-test.ts + - auth-client-test.ts + - queries-test.ts steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 - uses: ./.github/actions/init @@ -507,7 +513,7 @@ jobs: run: pnpm test:wait-for-servers working-directory: packages/realm-server env: - TEST_MODULE: ${{matrix.testModule}} + TEST_MODULES: ${{ join(matrix.testModules, '|') }} - name: Print realm server logs if: always() run: cat /tmp/server.log @@ -515,7 +521,7 @@ jobs: id: artifact_name if: always() run: | - export SAFE_ARTIFACT_NAME=$(echo ${{ matrix.testModule }} | sed 's/[/]/_/g') + export SAFE_ARTIFACT_NAME=shard-${{ matrix.shard }} echo "artifact_name=$SAFE_ARTIFACT_NAME" >> "$GITHUB_OUTPUT" - name: Upload realm server log uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 diff --git a/README.md b/README.md index 9a8d18a99c..f94bfbda4c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ You can also use `start:development` if you want the functionality of `start:all Optional environment variables for `start:development`: -- `USE_EXTERNAL_CATALOG=1` to load `/catalog` from `packages/catalog/contents` (cloned from the `boxel-catalog` repo). +- `USE_EXTERNAL_CATALOG=1` to load `/catalog` from `packages/catalog/contents` (cloned from the `boxel-catalog` repo). ### Card Pre-rendering @@ -394,12 +394,15 @@ The tests are available at `http://localhost:4200/tests` ### Realm Server Node tests -First make sure to generate the host app's `dist/` output in order to support card pre-rendering by first starting the host app (instructions above). If you want to make the host app's `dist/` output without starting the host app, you can run `pnpm build` in the host app's workspace. - To run the `packages/realm-server/` workspace tests start: -1. `pnpm start:all` in the `packages/realm-server/` to serve _both_ the base realm and the realm that serves the test cards for node. -2. Run `pnpm test` in the `packages/realm-server/` workspace to run the realm node tests. `TEST_MODULE=realm-endpoints-test.ts pnpm test-module` is an example of how to run a single test module. +1. The host application on port 4200. You can do this by running `pnpm start` in the `packages/host/` workspace, or if you have a built folder you can serve it with a static server with `pnpm serve:dist`. +2. The base realm and associated workers, postgres and synapse. You can do this by running `pnpm start:all` in the `packages/realm-server/` workspace, or `pnpm:start-services-for-host-tests` for a more lightweight setup. +3. Run the realm server tests: + +- `pnpm test` in the `packages/realm-server/` workspace to run the realm node tests in full (~1hr). +- `TEST_MODULES="types-endpoint-test.ts|another-test-module.ts" pnpm test` in the `packages/realm-server/` workspace to run tests for a subset of modules. +- `TEST_MODULE="types-endpoint-test.ts" pnpm test-module` in the `packages/realm-server/` workspace to run tests for a specific module. ### Boxel UI diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index 0ff3f94e86..7647685a27 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -87,7 +87,7 @@ "start:smtp": "cd ../matrix && pnpm assert-smtp-running", "start:pg": "./scripts/start-pg.sh", "stop:pg": "./scripts/stop-pg.sh", - "test:wait-for-servers": "WAIT_ON_TIMEOUT=900000 NODE_NO_WARNINGS=1 start-server-and-test 'pnpm run wait' 'http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' 'pnpm run wait' 'http-get://localhost:4202/node-test/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson|http://localhost:8008|http://localhost:5001' 'test-module'", + "test:wait-for-servers": "WAIT_ON_TIMEOUT=900000 NODE_NO_WARNINGS=1 start-server-and-test 'pnpm run wait' 'http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' 'pnpm run wait' 'http-get://localhost:4202/node-test/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson|http://localhost:8008|http://localhost:5001' 'test'", "setup:base-in-deployment": "mkdir -p /persistent/base && rsync --dry-run --itemize-changes --checksum --recursive --delete ../base/. /persistent/base/ && rsync --checksum --recursive --delete ../base/. /persistent/base/", "setup:experiments-in-deployment": "mkdir -p /persistent/experiments && rsync --dry-run --itemize-changes --checksum --recursive ../experiments-realm/. /persistent/experiments/ && rsync --checksum --recursive ../experiments-realm/. /persistent/experiments/", "setup:catalog-in-deployment": "mkdir -p /persistent/catalog && rsync --dry-run --itemize-changes --checksum --recursive --delete ../catalog-realm/. /persistent/catalog/ && rsync --checksum --recursive --delete ../catalog-realm/. /persistent/catalog/", diff --git a/packages/realm-server/scripts/lint-test-shards.ts b/packages/realm-server/scripts/lint-test-shards.ts index f0df182e83..4576ce5225 100755 --- a/packages/realm-server/scripts/lint-test-shards.ts +++ b/packages/realm-server/scripts/lint-test-shards.ts @@ -19,18 +19,49 @@ function getCiTestModules(yamlFilePath: string) { const yamlContent = readFileSync(yamlFilePath, 'utf8'); const yamlData = yaml.load(yamlContent) as Record; - const shardIndexes: string[] = - yamlData?.jobs?.['realm-server-test']?.strategy?.matrix?.testModule; + const matrix = yamlData?.jobs?.['realm-server-test']?.strategy?.matrix; + const testModules = matrix?.testModule; - if (!Array.isArray(shardIndexes)) { + if (Array.isArray(testModules)) { + return testModules; + } + + const include = matrix?.include; + if (!Array.isArray(include)) { throw new Error( - `Invalid 'jobs.realm-server-test.strategy.matrix.testModule' format in the YAML file.`, + `Invalid 'jobs.realm-server-test.strategy.matrix' format in the YAML file.`, ); } - return shardIndexes; + const modules = new Set(); + const invalidEntries: number[] = []; + include.forEach((entry: Record, index: number) => { + const entryModules = entry?.testModules; + if (Array.isArray(entryModules)) { + entryModules.forEach((moduleName: string) => modules.add(moduleName)); + return; + } + if (typeof entryModules === 'string') { + entryModules + .split(/[,\s]+/) + .filter(Boolean) + .forEach((moduleName) => modules.add(moduleName)); + return; + } + invalidEntries.push(index); + }); + + if (invalidEntries.length > 0) { + throw new Error( + `Invalid 'jobs.realm-server-test.strategy.matrix.include[*].testModules' entries at indexes: ${invalidEntries.join(', ')}`, + ); + } + + return Array.from(modules); } catch (error: any) { - console.error(`Error reading shardIndex from YAML file: ${error.message}`); + console.error( + `Error reading test modules from YAML file: ${error.message}`, + ); process.exit(1); } } diff --git a/packages/realm-server/scripts/remove-test-dbs.sh b/packages/realm-server/scripts/remove-test-dbs.sh index 43028801ab..1b7c07fd13 100755 --- a/packages/realm-server/scripts/remove-test-dbs.sh +++ b/packages/realm-server/scripts/remove-test-dbs.sh @@ -10,8 +10,28 @@ for pid in $isolated_realm_processes; do kill -9 $pid done -databases=$(docker exec boxel-pg psql -U postgres -w -lqt | cut -d \| -f 1 | grep -E 'test_db_' | tr -d ' ') echo "cleaning up old test databases..." -for db in $databases; do - docker exec boxel-pg dropdb -U postgres -w $db -done +docker exec -i boxel-pg psql -X -U postgres -d postgres -v ON_ERROR_STOP=0 <<'SQL' +\set AUTOCOMMIT on +COMMIT; + +-- (optional) kick anyone out first (as separate statements) +SELECT format( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %L AND pid <> pg_backend_pid();', + datname +) +FROM pg_database +WHERE datname ~ '^test_db_' + AND datname <> current_database() +ORDER BY datname +\gexec + +-- now drop (ONE statement per row) +SELECT format('DROP DATABASE %I;', datname) +FROM pg_database +WHERE datname ~ '^test_db_' + AND datname <> current_database() +ORDER BY datname +\gexec +SQL +echo "Cleaned up old test databases." \ No newline at end of file diff --git a/packages/realm-server/scripts/run-test-modules.js b/packages/realm-server/scripts/run-test-modules.js new file mode 100644 index 0000000000..cf02471381 --- /dev/null +++ b/packages/realm-server/scripts/run-test-modules.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +'use strict'; + +const { spawnSync } = require('node:child_process'); + +function buildModuleFilter(modulesToMatch) { + const escaped = modulesToMatch + .map((moduleName) => escapeRegex(moduleName)) + .join('|'); + const pattern = `^(?:${escaped})(?:\\s>\\s|:)`; + return `/${pattern}/`; +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '\\/'); +} + +const rawModules = process.env.TEST_MODULES ?? ''; +const cleanedRaw = rawModules.trim(); + +if (!cleanedRaw) { + console.error('TEST_MODULES must be set.'); + process.exit(1); +} + +const modules = cleanedRaw + .split(/[|,]/) + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => value.replace(/^['"]+|['"]+$/g, '')); + +if (modules.length === 0) { + console.error('No module names found in TEST_MODULES.'); + process.exit(1); +} + +const args = ['--require', 'ts-node/register/transpile-only']; + +args.push('--filter', buildModuleFilter(modules)); + +args.push('tests/index.ts'); + +const qunitBin = require.resolve('qunit/bin/qunit.js'); +const result = spawnSync(process.execPath, [qunitBin, ...args], { + stdio: 'inherit', + env: process.env, +}); + +if (typeof result.status === 'number') { + process.exit(result.status); +} + +if (result.error) { + console.error(result.error); +} + +process.exit(1); diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index fe6d5c71d1..2ec3d4752f 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -1,13 +1,17 @@ import { writeFileSync, writeJSONSync, + readFileSync, + utimesSync, readdirSync, statSync, ensureDirSync, - copySync, } from 'fs-extra'; import { NodeAdapter } from '../../node-realm'; -import { join } from 'path'; +import { dirname, isAbsolute, join } from 'path'; +import { createHash } from 'crypto'; +import { spawn } from 'child_process'; +import { Client } from 'pg'; import type { LooseSingleCardDocument, RealmPermissions, @@ -42,7 +46,7 @@ import { CachingDefinitionLookup, } from '@cardstack/runtime-common'; import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; -import { dirSync, setGracefulCleanup, type DirResult } from 'tmp'; +import { dirSync, file, setGracefulCleanup, type DirResult } from 'tmp'; import { getLocalConfig as getSynapseConfig } from '../../synapse'; import { RealmServer } from '../../server'; @@ -50,6 +54,7 @@ import { PgAdapter, PgQueuePublisher, PgQueueRunner, + postgresConfig, } from '@cardstack/postgres'; import type { Server } from 'http'; import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; @@ -70,9 +75,14 @@ import type { import { createRemotePrerenderer } from '../../prerender/remote-prerenderer'; import { createPrerenderHttpServer } from '../../prerender/prerender-app'; import { buildCreatePrerenderAuth } from '../../prerender/auth'; +import { glob } from 'glob'; +import { template } from 'lodash'; const testRealmURL = new URL('http://127.0.0.1:4444/'); const testRealmHref = testRealmURL.href; +const processStartTimeMs = Date.now(); + +const TEMPLATE_DB_PREFIX = 'test_template'; export const testRealmServerMatrixUsername = 'node-test_realm-server'; export const testRealmServerMatrixUserId = `@${testRealmServerMatrixUsername}:localhost`; @@ -129,6 +139,221 @@ export const testRealmInfo = { lastPublishedAt: null, }; +export function buildCardFileSystem(entries: string[]): Record { + let cardsDir = join(__dirname, '..', 'cards'); + let fileSystem: Record = {}; + + for (let entry of entries) { + let normalized = entry.replace(/^\.\//, ''); + if (isAbsolute(entry) || normalized.split('/').includes('..')) { + throw new Error(`Card entry must be within ../cards: ${entry}`); + } + + let matches: string[] = []; + let fullPath = join(cardsDir, normalized); + + try { + let stats = statSync(fullPath); + if (stats.isDirectory()) { + matches = glob.sync(`${normalized}/**/*`, { + cwd: cardsDir, + nodir: true, + dot: true, + }); + } else { + matches = [normalized]; + } + } catch { + matches = glob.sync(normalized, { cwd: cardsDir, dot: true }); + } + + if (matches.length === 0) { + throw new Error(`No card files matched: ${entry}`); + } + + for (let match of matches) { + let matchPath = join(cardsDir, match); + let stats = statSync(matchPath); + if (stats.isDirectory()) { + let nestedMatches = glob.sync(`${match}/**/*`, { + cwd: cardsDir, + nodir: true, + dot: true, + }); + for (let nested of nestedMatches) { + if (!fileSystem[nested]) { + fileSystem[nested] = readFileSync(join(cardsDir, nested), 'utf8'); + } + } + } else if (!fileSystem[match]) { + fileSystem[match] = readFileSync(matchPath, 'utf8'); + } + } + } + + return fileSystem; +} + +function stableStringify(value: unknown): string { + let seen = new WeakSet(); + return JSON.stringify(value, (_key, val) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + if (seen.has(val)) { + return '[Circular]'; + } + seen.add(val); + let sorted: Record = {}; + for (let key of Object.keys(val).sort()) { + sorted[key] = (val as Record)[key]; + } + return sorted; + } + return val; + }); +} + +function safeDbName(name: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(name)) { + throw new Error(`Unsafe database name: ${name}`); + } + return name; +} + +function adminDbConfig() { + let config = postgresConfig(); + return { ...config, database: 'postgres' }; +} + +async function runCommand( + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): Promise { + await new Promise((resolve, reject) => { + let stderr = ''; + let child = spawn(command, args, { env }); + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} exited with code ${code}: ${stderr}`)); + } + }); + }); +} + +async function createTemplateDb(options: { + sourceDbName: string; + templateDbName: string; +}): Promise { + let client = new Client(adminDbConfig()); + let dumpPath = `/tmp/${options.templateDbName}.dump`; + await client.connect(); + try { + let existing = await client.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [options.templateDbName], + ); + if (existing.rowCount > 0) { + return; + } + + let templateDbName = safeDbName(options.templateDbName); + let sourceDbName = safeDbName(options.sourceDbName); + await client.query(`CREATE DATABASE ${templateDbName}`); + + await runCommand( + 'docker', + [ + 'exec', + 'boxel-pg', + 'pg_dump', + '--format=custom', + '--file', + dumpPath, + '--username', + 'postgres', + sourceDbName, + ], + process.env, + ); + + await runCommand( + 'docker', + [ + 'exec', + 'boxel-pg', + 'pg_restore', + '--no-owner', + '--no-privileges', + '--username', + 'postgres', + '--dbname', + templateDbName, + dumpPath, + ], + process.env, + ); + + await client.query(`ALTER DATABASE ${templateDbName} IS_TEMPLATE true`); + await client.query( + `ALTER DATABASE ${templateDbName} WITH ALLOW_CONNECTIONS false`, + ); + } finally { + try { + await runCommand( + 'docker', + ['exec', 'boxel-pg', 'rm', '-f', dumpPath], + process.env, + ); + } catch { + // ignore cleanup failures + } + await client.end(); + } +} + +async function restoreDbFromTemplate(options: { + targetDbName: string; + templateDbName: string; +}): Promise { + let client = new Client(adminDbConfig()); + await client.connect(); + try { + let existing = await client.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [options.templateDbName], + ); + if (existing.rowCount === 0) { + return false; + } + + let templateDbName = safeDbName(options.templateDbName); + let targetDbName = safeDbName(options.targetDbName); + await client.query( + `CREATE DATABASE ${targetDbName} WITH TEMPLATE ${templateDbName}`, + ); + return true; + } finally { + await client.end(); + } +} + +async function dropTestDb(dbName: string): Promise { + let client = new Client(adminDbConfig()); + await client.connect(); + try { + let safeName = safeDbName(dbName); + await client.query(`DROP DATABASE IF EXISTS ${safeName} WITH (FORCE)`); + } finally { + await client.end(); + } +} + export const realmServerTestMatrix: MatrixConfig = { url: matrixURL, username: 'node-test_realm-server', @@ -256,6 +481,7 @@ async function startTestPrerenderServer(): Promise { } let server = createPrerenderHttpServer({ silent: Boolean(process.env.SILENT_PRERENDERER), + maxPages: 1, }); prerenderServer = server; trackServer(server); @@ -308,6 +534,7 @@ export function setupDB( beforeEach?: BeforeAfterCallback; afterEach?: BeforeAfterCallback; } = {}, + templateDbName?: string, ) { let dbAdapter: PgAdapter; let publisher: QueuePublisher; @@ -315,6 +542,27 @@ export function setupDB( const runBeforeHook = async () => { prepareTestDB(); + if (templateDbName) { + let targetDbName = + process.env.PGDATABASE ?? + `test_db_${Math.floor(10000000 * Math.random())}`; + try { + let restored = await restoreDbFromTemplate({ + targetDbName, + templateDbName, + }); + if (!restored) { + console.warn( + `[template-db] template not found: ${templateDbName}, continuing without restore`, + ); + } + } catch (error) { + console.warn( + `[template-db] failed to restore from ${templateDbName}, continuing without restore`, + error, + ); + } + } dbAdapter = new PgAdapter({ autoMigrate: true }); trackedDbAdapters.add(dbAdapter); publisher = new PgQueuePublisher(dbAdapter); @@ -339,6 +587,17 @@ export function setupDB( if (dbAdapter) { trackedDbAdapters.delete(dbAdapter); } + + if (process.env.PGDATABASE) { + try { + await dropTestDb(process.env.PGDATABASE); + } catch (error) { + console.warn( + `[test-setup] failed to drop test db ${process.env.PGDATABASE}`, + error, + ); + } + } await stopTestPrerenderServer(); }; @@ -420,11 +679,17 @@ export async function createRealm({ await insertPermissions(dbAdapter, new URL(realmURL), permissions); for (let [filename, contents] of Object.entries(fileSystem)) { + let fullPath = join(dir, filename); + ensureDirSync(dirname(fullPath)); + if (typeof contents === 'string') { - writeFileSync(join(dir, filename), contents); + writeFileSync(fullPath, contents); } else { - writeJSONSync(join(dir, filename), contents); + writeJSONSync(fullPath, contents); } + + let mtime = new Date(processStartTimeMs); + utimesSync(fullPath, mtime, mtime); } let adapter = new NodeAdapter(dir, enableFileWatcher); @@ -1102,33 +1367,56 @@ export function setupPermissionedRealm( published?: boolean; }, ) { + if (!fileSystem) { + fileSystem = buildCardFileSystem(['**/*']); + } let testRealmServer: Awaited>; + let resolvedRealmURL = realmURL ?? testRealmURL; setGracefulCleanup(); - setupDB(hooks, { - [mode]: async ( - dbAdapter: PgAdapter, - publisher: QueuePublisher, - runner: QueueRunner, - ) => { - let resolvedRealmURL = realmURL ?? testRealmURL; - let dir = dirSync(); - - let testRealmDir; - - if (published) { - let publishedRealmId = uuidv4(); - - testRealmDir = join( - dir.name, - 'realm_server_1', - PUBLISHED_DIRECTORY_NAME, - publishedRealmId, - ); + let templateDbName = undefined; + // Only create a template DB for non-published realms + // based on their permissions and filesystem + if (!published) { + let hash = createHash('sha256') + .update( + stableStringify({ + permissions, + fileSystem, + published, + resolvedRealmURL: resolvedRealmURL.href, + }), + ) + .digest('hex') + .slice(0, 16); + templateDbName = `${TEMPLATE_DB_PREFIX}_${hash}_${process.pid}`; + } - dbAdapter.execute( - `INSERT INTO + setupDB( + hooks, + { + [mode]: async ( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, + ) => { + let dir = dirSync(); + + let testRealmDir; + + if (published) { + let publishedRealmId = uuidv4(); + + testRealmDir = join( + dir.name, + 'realm_server_1', + PUBLISHED_DIRECTORY_NAME, + publishedRealmId, + ); + + dbAdapter.execute( + `INSERT INTO published_realms (id, owner_username, source_realm_url, published_realm_url) VALUES @@ -1138,47 +1426,58 @@ export function setupPermissionedRealm( 'http://example.localhost/source', '${resolvedRealmURL.href}' )`, - ); - } else { - testRealmDir = join(dir.name, 'realm_server_1', 'test'); - } + ); + } else { + testRealmDir = join(dir.name, 'realm_server_1', 'test'); + } - ensureDirSync(testRealmDir); + ensureDirSync(testRealmDir); - // If a fileSystem is provided, use it to populate the test realm, otherwise copy the default cards - if (!fileSystem) { - copySync(join(__dirname, '..', 'cards'), testRealmDir); - } + let virtualNetwork = createVirtualNetwork(); - let virtualNetwork = createVirtualNetwork(); - - testRealmServer = await runTestRealmServer({ - virtualNetwork, - testRealmDir, - realmsRootPath: join(dir.name, 'realm_server_1'), - realmURL: resolvedRealmURL, - permissions, - dbAdapter, - runner, - publisher, - matrixURL, - fileSystem, - enableFileWatcher: subscribeToRealmEvents, - }); + testRealmServer = await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_1'), + realmURL: resolvedRealmURL, + permissions, + dbAdapter, + runner, + publisher, + matrixURL, + fileSystem, + enableFileWatcher: subscribeToRealmEvents, + }); + if (templateDbName) { + try { + let sourceDbName = + process.env.PGDATABASE ?? + dbAdapter.url.split('/').pop() ?? + 'boxel'; + await createTemplateDb({ + sourceDbName, + templateDbName, + }); + } catch (error) { + console.warn('[template-db] failed to create template', error); + } + } - let request = supertest(testRealmServer.testRealmHttpServer); + let request = supertest(testRealmServer.testRealmHttpServer); - onRealmSetup?.({ - dbAdapter, - testRealm: testRealmServer.testRealm, - testRealmPath: testRealmServer.testRealmDir, - testRealmHttpServer: testRealmServer.testRealmHttpServer, - testRealmAdapter: testRealmServer.testRealmAdapter, - request, - dir, - }); + onRealmSetup?.({ + dbAdapter, + testRealm: testRealmServer.testRealm, + testRealmPath: testRealmServer.testRealmDir, + testRealmHttpServer: testRealmServer.testRealmHttpServer, + testRealmAdapter: testRealmServer.testRealmAdapter, + request, + dir, + }); + }, }, - }); + templateDbName, + ); hooks[mode === 'beforeEach' ? 'afterEach' : 'after'](async function () { testRealmServer.testRealm.unsubscribe(); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index c4f342e56a..d1c2187764 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -30,6 +30,21 @@ import * as ContentTagGlobal from 'content-tag'; import QUnit from 'qunit'; QUnit.config.testTimeout = 60000; +const testModules = process.env.TEST_MODULES?.trim(); + +if (testModules) { + const modules = parseModules(testModules); + if (modules.length > 0) { + QUnit.config.filter = buildModuleFilter(modules); + console.log( + `Filtering tests to modules from TEST_MODULES: ${modules.join(', ')}`, + ); + } else { + console.warn( + 'TEST_MODULES was provided but no module names were parsed. Running full suite.', + ); + } +} // Cleanup here ensures lingering servers/prerenderers/queues don't keep the // Node event loop alive after tests finish. @@ -156,3 +171,21 @@ import './delete-boxel-claimed-domain-test'; import './realm-auth-test'; import './queries-test'; import './remote-prerenderer-test'; + +function parseModules(value: string): string[] { + return value + .split(/[|,]/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^['"]+|['"]+$/g, '')); +} + +function buildModuleFilter(modulesToMatch: string[]): string { + const escaped = modulesToMatch.map((moduleName) => escapeRegex(moduleName)); + const pattern = `^(?:${escaped.join('|')})(?:\\s>\\s|:)`; + return `/${pattern}/`; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '\\/'); +} diff --git a/packages/realm-server/tests/indexing-test.ts b/packages/realm-server/tests/indexing-test.ts index 6fd632433d..dc749e37b1 100644 --- a/packages/realm-server/tests/indexing-test.ts +++ b/packages/realm-server/tests/indexing-test.ts @@ -1,36 +1,21 @@ import { module, test } from 'qunit'; -import { dirSync } from 'tmp'; import { SupportedMimeType } from '@cardstack/runtime-common'; import type { - DBAdapter, LooseSingleCardDocument, Realm, RealmPermissions, RealmAdapter, } from '@cardstack/runtime-common'; -import type { - IndexedInstance, - QueuePublisher, - QueueRunner, -} from '@cardstack/runtime-common'; +import type { IndexedInstance } from '@cardstack/runtime-common'; import { - setupDB, - createVirtualNetwork, - matrixURL, cleanWhiteSpace, - runTestRealmServer, - closeServer, setupPermissionedRealms, cardInfo, + setupPermissionedRealm, } from './helpers'; import stripScopedCSSAttributes from '@cardstack/runtime-common/helpers/strip-scoped-css-attributes'; -import { join, basename } from 'path'; -import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms'; -import type { - PgQueueRunner, - PgAdapter, - PgQueuePublisher, -} from '@cardstack/postgres'; +import { basename } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; function trimCardContainer(text: string) { return cleanWhiteSpace(text) @@ -41,11 +26,8 @@ function trimCardContainer(text: string) { ); } -let testDbAdapter: DBAdapter; const testRealm = new URL('http://127.0.0.1:4445/test/'); -type TestRealmServerResult = Awaited>; - function makeTestRealmFileSystem(): Record< string, string | LooseSingleCardDocument @@ -416,45 +398,10 @@ function makeTestRealmFileSystem(): Record< }; } -async function startTestRealm({ - dbAdapter, - publisher, - runner, -}: { - dbAdapter: DBAdapter; - publisher: QueuePublisher; - runner: QueueRunner; -}): Promise { - let virtualNetwork = createVirtualNetwork(); - let dir = dirSync().name; - let testRealmServer = await runTestRealmServer({ - testRealmDir: dir, - realmsRootPath: join(dir, 'realm_server_1'), - virtualNetwork, - realmURL: testRealm, - dbAdapter: dbAdapter as PgAdapter, - publisher: publisher as PgQueuePublisher, - runner: runner as PgQueueRunner, - matrixURL, - fileSystem: makeTestRealmFileSystem(), - }); - await testRealmServer.testRealm.start(); - return testRealmServer; -} - -async function stopTestRealm(testRealmServer?: TestRealmServerResult) { - if (!testRealmServer) { - return; - } - testRealmServer.testRealm.unsubscribe(); - await closeServer(testRealmServer.testRealmHttpServer); - resetCatalogRealms(); -} - module(basename(__filename), function () { module('indexing (read only)', function (hooks) { let realm: Realm; - let testRealmServer: TestRealmServerResult | undefined; + let testDbAdapter: PgAdapter; async function getInstance( realm: Realm, @@ -467,20 +414,18 @@ module(basename(__filename), function () { return maybeInstance as IndexedInstance | undefined; } - setupDB(hooks, { - before: async (dbAdapter, publisher, runner) => { - testDbAdapter = dbAdapter; - testRealmServer = await startTestRealm({ - dbAdapter, - publisher, - runner, - }); - realm = testRealmServer.testRealm; - }, - after: async () => { - await stopTestRealm(testRealmServer); - testRealmServer = undefined; + function onRealmSetup(args: { testRealm: Realm; dbAdapter: PgAdapter }) { + realm = args.testRealm; + testDbAdapter = args.dbAdapter; + } + + setupPermissionedRealm(hooks, { + permissions: { + '*': ['read'], }, + realmURL: testRealm, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup, }); test('realm is full indexed at boot', async function (assert) { @@ -1017,23 +962,24 @@ module(basename(__filename), function () { module('indexing (mutating)', function (hooks) { let realm: Realm; let adapter: RealmAdapter; - let testRealmServer: TestRealmServerResult | undefined; - - setupDB(hooks, { - beforeEach: async (dbAdapter, publisher, runner) => { - testDbAdapter = dbAdapter; - testRealmServer = await startTestRealm({ - dbAdapter, - publisher, - runner, - }); - realm = testRealmServer.testRealm; - adapter = testRealmServer.testRealmAdapter; - }, - afterEach: async () => { - await stopTestRealm(testRealmServer); - testRealmServer = undefined; - }, + let testDbAdapter: PgAdapter; + + function onRealmSetup(args: { + testRealm: Realm; + testRealmAdapter: RealmAdapter; + dbAdapter: PgAdapter; + }) { + realm = args.testRealm; + adapter = args.testRealmAdapter; + testDbAdapter = args.dbAdapter; + } + setupPermissionedRealm(hooks, { + permissions: { + '*': ['read', 'write'], + } as RealmPermissions, + realmURL: testRealm, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup, }); test('can incrementally index updated instance', async function (assert) { @@ -1488,29 +1434,31 @@ module(basename(__filename), function () { 'the deleted type results in no card instance results', ); } - let actual = await realm.realmIndexQueryEngine.cardDocument( - new URL(`${testRealm}post-1`), - ); - if (actual?.type === 'error') { - assert.ok(actual.error.errorDetail.stack, 'stack trace is included'); - delete actual.error.errorDetail.stack; - assert.deepEqual( - // we splat because despite having the same shape, the constructors are different - { ...actual.error.errorDetail }, - { - id: `${testRealm}post`, - isCardError: true, - additionalErrors: null, - message: `missing file ${testRealm}post`, - status: 404, - title: 'Link Not Found', - deps: [`${testRealm}post`], - }, - 'card instance is an error document', + // Wait until the error document has a stack trace as expected + let retries = 10; + let hasStackTrace = false; + while (retries > 0 && !hasStackTrace) { + let actual = await realm.realmIndexQueryEngine.cardDocument( + new URL(`${testRealm}post-1`), ); - } else { - assert.ok(false, 'search index entry is not an error document'); + if (actual?.type === 'error') { + // Should have a stack trace + if ( + actual.error.errorDetail.stack && + actual.error.errorDetail.message === + `missing file ${testRealm}post` && + actual.error.errorDetail.id === `${testRealm}post` + ) { + hasStackTrace = true; + break; + } + } + // Wait and check again + console.log('waiting for error document to have stack trace...'); + retries--; + await new Promise((resolve) => setTimeout(resolve, 500)); } + assert.ok(hasStackTrace, 'error document has stack trace as expected'); // when the definitions is created again, the instance should mend its broken link await realm.write( diff --git a/packages/realm-server/tests/realm-endpoints-test.ts b/packages/realm-server/tests/realm-endpoints-test.ts index 8a00f4eb1b..ebcc75fcff 100644 --- a/packages/realm-server/tests/realm-endpoints-test.ts +++ b/packages/realm-server/tests/realm-endpoints-test.ts @@ -60,8 +60,6 @@ import type { RealmEventContent, } from 'https://cardstack.com/base/matrix-event'; -const testRealm2URL = new URL('http://127.0.0.1:4445/test/'); - module(basename(__filename), function () { module('Realm-specific Endpoints', function (hooks) { let testRealm: Realm; @@ -69,12 +67,6 @@ module(basename(__filename), function () { let request: SuperTest; let dir: DirResult; let dbAdapter: PgAdapter; - let testRealmHttpServer2: Server; - let testRealm2: Realm; - let dbAdapter2: PgAdapter; - let publisher: QueuePublisher; - let runner: QueueRunner; - let testRealmDir: string; function onRealmSetup(args: { testRealm: Realm; @@ -110,45 +102,6 @@ module(basename(__filename), function () { }); let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); - let virtualNetwork = createVirtualNetwork(); - - async function startRealmServer( - dbAdapter: PgAdapter, - publisher: QueuePublisher, - runner: QueueRunner, - ) { - if (testRealm2) { - virtualNetwork.unmount(testRealm2.handle); - } - ({ testRealm: testRealm2, testRealmHttpServer: testRealmHttpServer2 } = - await runTestRealmServer({ - virtualNetwork, - testRealmDir, - realmsRootPath: join(dir.name, 'realm_server_2'), - realmURL: testRealm2URL, - dbAdapter, - publisher, - runner, - matrixURL, - })); - - await testRealm.logInToMatrix(); - } - - setupDB(hooks, { - beforeEach: async (_dbAdapter, _publisher, _runner) => { - dbAdapter2 = _dbAdapter; - publisher = _publisher; - runner = _runner; - testRealmDir = join(dir.name, 'realm_server_2', 'test'); - ensureDirSync(testRealmDir); - copySync(join(__dirname, 'cards'), testRealmDir); - await startRealmServer(dbAdapter2, publisher, runner); - }, - afterEach: async () => { - await closeServer(testRealmHttpServer2); - }, - }); test('can set response ETag and Cache-Control headers for module request', async function (assert) { let response = await request