diff --git a/reference-implementation/scripts/run-tests-failure.js b/reference-implementation/scripts/run-tests-failure.js new file mode 100644 index 000000000..ba88933d0 --- /dev/null +++ b/reference-implementation/scripts/run-tests-failure.js @@ -0,0 +1,15 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +export function combineTestAndCleanupFailure(testError, cleanupError, context) { + if (!testError) return cleanupError; + if (!cleanupError) return testError; + return new AggregateError([testError, cleanupError], `${context} and database cleanup both failed`); +} + +export function testProcessExitFailure(filePath, code, signal, output) { + const outcome = signal ? `exited via signal ${signal}` : `exited with code ${code}`; + const error = new Error(`Test process for ${filePath} ${outcome}\n${output}`); + error.output = output; + return error; +} diff --git a/reference-implementation/scripts/run-tests-failure.test.mjs b/reference-implementation/scripts/run-tests-failure.test.mjs new file mode 100644 index 000000000..9c7f0e239 --- /dev/null +++ b/reference-implementation/scripts/run-tests-failure.test.mjs @@ -0,0 +1,27 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { combineTestAndCleanupFailure, testProcessExitFailure } from './run-tests-failure.js'; + +test('runner preserves spawn and cleanup failures together', () => { + const spawnError = new Error('spawn failed'); + const cleanupError = new Error('drop failed'); + const failure = combineTestAndCleanupFailure(spawnError, cleanupError, 'could not start test process'); + + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [spawnError, cleanupError]); +}); + +test('runner preserves failed child output and cleanup failure together', () => { + const output = '\n==> test/example.test.js\nnot ok 1 - expected pass\n'; + const childError = testProcessExitFailure('test/example.test.js', 1, null, output); + const cleanupError = new Error('drop failed'); + const failure = combineTestAndCleanupFailure(childError, cleanupError, 'test process failed'); + + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [childError, cleanupError]); + assert.equal(failure.errors[0].output, output); +}); diff --git a/reference-implementation/scripts/run-tests.js b/reference-implementation/scripts/run-tests.js index 50bb900c5..cd780de60 100644 --- a/reference-implementation/scripts/run-tests.js +++ b/reference-implementation/scripts/run-tests.js @@ -6,8 +6,10 @@ import { availableParallelism } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import pg from 'pg'; import { buildScrubbedTestEnv } from './test-env.js'; +import { combineTestAndCleanupFailure, testProcessExitFailure } from './run-tests-failure.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = dirname(__dirname); @@ -26,6 +28,7 @@ const requestedConcurrency = Number.parseInt(process.env.PDPP_TEST_CONCURRENCY | // requiring any changes to individual test files. let fileCounter = 0; +const runnerId = randomBytes(4).toString('hex'); /** * Derive the admin connection URL from a per-test URL by replacing the @@ -40,8 +43,8 @@ function adminUrlFromBase(baseUrl) { } /** - * Derive a short, safe DB name from the test file path and a monotonic - * counter so concurrent workers never collide. + * Derive a short, safe DB name from the test file path, a per-run random ID, + * and a monotonic counter so concurrent runners and workers never collide. */ function deriveDbName(filePath) { // Strip directory and extension; keep only alphanumeric/underscore chars. @@ -52,9 +55,11 @@ function deriveDbName(filePath) { .replace(/\.[^.]+$/, '') .replace(/[^a-z0-9_]/gi, '_') .toLowerCase() - .slice(0, 40); + // PostgreSQL identifiers are limited to 63 bytes. The fixed prefix, + // random ID, and a base-36 counter leave 38 bytes for the file stem. + .slice(0, 38); fileCounter += 1; - return `pdpp_test_${base}_${fileCounter}`; + return `pdpp_test_${base}_${runnerId}_${fileCounter.toString(36)}`; } // Per-file databases currently allocated. Tracked so that if the runner @@ -93,6 +98,10 @@ async function allocateTestDb(filePath, baseUrl) { try { await client.connect(); // Identifier is safe: deriveDbName produces only [a-z0-9_] chars. + // A prior hard-killed runner can leave an allocated name behind. Remove + // it before allocating a fresh DB; the per-run random ID avoids collisions + // with any concurrently running test runner. + await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); await client.query(`CREATE DATABASE "${dbName}"`); await client.end(); } catch (err) { @@ -106,18 +115,31 @@ async function allocateTestDb(filePath, baseUrl) { testUrl.pathname = `/${dbName}`; const allocation = { url: testUrl.toString() }; + let releasePromise; - allocation.release = async function release() { - activeAllocations.delete(allocation); - const drop = new pg.Client({ connectionString: adminUrl }); - try { - await drop.connect(); - await drop.query(`DROP DATABASE IF EXISTS "${dbName}"`); - await drop.end(); - } catch (err) { - try { await drop.end(); } catch (_) {} - process.stderr.write(`[run-tests] WARN: could not drop test DB ${dbName}: ${err.message}\n`); - } + allocation.release = function release() { + if (releasePromise) return releasePromise; + + releasePromise = (async () => { + const drop = new pg.Client({ connectionString: adminUrl }); + try { + await drop.connect(); + // FORCE also closes a leaked test pool, so cleanup cannot silently leave + // a per-file database behind after a failed test. + await drop.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); + await drop.end(); + activeAllocations.delete(allocation); + } catch (err) { + try { await drop.end(); } catch (_) {} + throw new Error(`[run-tests] could not drop test DB ${dbName}: ${err.message}`, { cause: err }); + } + })().catch((error) => { + // Keep failed allocations eligible for signal cleanup to retry. + releasePromise = undefined; + throw error; + }); + + return releasePromise; }; // Track the live allocation and arm the runner-level signal cleanup so a @@ -149,6 +171,13 @@ async function runNodeTest(filePath, extraArgs) { env: childEnv, }); let output = ''; + let settled = false; + + const settle = (outcome, value) => { + if (settled) return; + settled = true; + outcome(value); + }; child.stdout.on('data', (chunk) => { output += chunk.toString(); @@ -157,24 +186,45 @@ async function runNodeTest(filePath, extraArgs) { output += chunk.toString(); }); - child.on('error', (err) => { - if (allocation) allocation.release().finally(() => reject(err)); - else reject(err); + child.on('error', async (err) => { + let cleanupError; + try { + if (allocation) await allocation.release(); + } catch (error) { + cleanupError = error; + } + settle(reject, combineTestAndCleanupFailure(err, cleanupError, `could not start test process for ${filePath}`)); }); - child.on('exit', (code, signal) => { + child.on('exit', async (code, signal) => { + const testError = signal || code !== 0 + ? testProcessExitFailure(filePath, code, signal, `\n==> ${filePath}\n${output}`) + : undefined; const finish = () => { if (signal) { - reject(new Error(`Test process for ${filePath} exited via signal ${signal}`)); + settle(reject, testError); return; } - resolve({ + settle(resolve, { filePath, exitCode: code ?? 1, output: `\n==> ${filePath}\n${output}`, }); }; if (allocation) { - allocation.release().finally(finish); + let cleanupError; + try { + await allocation.release(); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + settle( + reject, + combineTestAndCleanupFailure(testError, cleanupError, `test process failed for ${filePath}`), + ); + return; + } + finish(); } else { finish(); } diff --git a/reference-implementation/test/aggregation-rows-conformance-postgres.test.js b/reference-implementation/test/aggregation-rows-conformance-postgres.test.js index 132f03ea3..36b527eca 100644 --- a/reference-implementation/test/aggregation-rows-conformance-postgres.test.js +++ b/reference-implementation/test/aggregation-rows-conformance-postgres.test.js @@ -17,7 +17,7 @@ * registered so the suite remains visible in CI output without failing. * * Target for local runs: - * docker run -d --name pg-pilot -p 55463:5432 \ + * docker run --rm -d --name pg-pilot -p 55463:5432 \ * -e POSTGRES_USER=pdpp -e POSTGRES_PASSWORD=pdpp \ * -e POSTGRES_DB=pdpp_pilot \ * pgvector/pgvector:pg16 diff --git a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js index 4c090b40d..e8aa25f24 100644 --- a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js +++ b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js @@ -35,6 +35,7 @@ import { reconcileConnectorSummaryEvidence } from '../server/connector-summary-e import { deleteAllRecordsForConnector, ingestRecord } from '../server/records.js'; import { closePostgresStorage, initPostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; const OWNER = 'owner_local'; const NOW = '2026-07-17T00:00:00.000Z'; @@ -99,18 +100,6 @@ async function seedInstancePostgres(connectorInstanceId, connectorId) { ); } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - let uniquePgDb = 0; /** @@ -121,25 +110,15 @@ let uniquePgDb = 0; * loopback-only test listener, dropped again on the way out. */ async function withTemporaryPostgres(fn) { - const pg = await import('pg'); - const { Pool } = pg.default; uniquePgDb += 1; - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_summary_throughput_${process.pid}_${Date.now()}_${uniquePgDb}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await initPostgresStorage({ backend: 'postgres', databaseUrl: databaseUrl(DEDICATED_POSTGRES_URL, database) }); - await fn(); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + async (databaseUrl) => { + await initPostgresStorage({ backend: 'postgres', databaseUrl }); + await fn(); + }, + ); } function storageTargetFor(connectorId, connectorInstanceId) { diff --git a/reference-implementation/test/device-batch-summary-evidence-convergence.test.js b/reference-implementation/test/device-batch-summary-evidence-convergence.test.js index a888e244c..f125db3c3 100644 --- a/reference-implementation/test/device-batch-summary-evidence-convergence.test.js +++ b/reference-implementation/test/device-batch-summary-evidence-convergence.test.js @@ -64,6 +64,7 @@ import { import { __setPostgresRecordSortBackfillPhaseHookForTest } from '../server/postgres-records.js'; import { closePostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; const DEDICATED_POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); const PROTOCOL_HEADERS = { 'X-PDPP-Collector-Protocol': COLLECTOR_PROTOCOL_VERSION }; @@ -189,36 +190,12 @@ function deviceIngestUrl(asUrl, device) { return `${asUrl}/_ref/device-exporters/${encodeURIComponent(device.device_id)}/ingest-batches`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTemporaryPostgres(fn) { - const pg = await import('pg'); - const { Pool } = pg.default; - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_devbatch_summary_${process.pid}_${Date.now()}_${unique}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await fn(databaseUrl(DEDICATED_POSTGRES_URL, database)); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + fn, + ); } function createDriver(kind, server) { diff --git a/reference-implementation/test/device-exporter-postgres-proof.test.js b/reference-implementation/test/device-exporter-postgres-proof.test.js index 5f44dd441..7e269b1a9 100644 --- a/reference-implementation/test/device-exporter-postgres-proof.test.js +++ b/reference-implementation/test/device-exporter-postgres-proof.test.js @@ -18,6 +18,7 @@ import { closeDb, getDb, initDb } from '../server/db.js'; import { ingestRecord, setClientEventEnqueueHook } from '../server/records.js'; import { makeLocalTransformerBackend } from '../server/search-semantic.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; import { bootstrapPostgresSchema, closePostgresStorage, @@ -43,37 +44,12 @@ function tempDbName() { return `pdpp_device_ingest_${process.pid}_${Date.now()}_${tempCounter}`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTempPostgres(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); const name = tempDbName(); - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - const url = databaseUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${name}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: name, closeConnections: closePostgresStorage }, + fn, + ); } async function closeServer(server) { diff --git a/reference-implementation/test/device-ingest-conformance.test.js b/reference-implementation/test/device-ingest-conformance.test.js index 941e13345..806ce53cf 100644 --- a/reference-implementation/test/device-ingest-conformance.test.js +++ b/reference-implementation/test/device-ingest-conformance.test.js @@ -6,8 +6,6 @@ import { createHash } from 'node:crypto'; import { setImmediate as yieldImmediate } from 'node:timers/promises'; import test from 'node:test'; -import pg from 'pg'; - import { __setRegisterConnectorPhaseHookForTest, registerConnector } from '../server/auth.js'; import { COLLECTOR_PROTOCOL_VERSION } from '../server/collector-protocol.ts'; import { @@ -33,8 +31,8 @@ import { configureSemanticBackend, semanticIndexBackfillForManifest } from '../s import { closePostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { __setPostgresRecordSortBackfillPhaseHookForTest } from '../server/postgres-records.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const DEDICATED_POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); const PROTOCOL_HEADERS = { 'X-PDPP-Collector-Protocol': COLLECTOR_PROTOCOL_VERSION }; let unique = 0; @@ -228,36 +226,12 @@ function deviceIngestUrl(asUrl, device) { return `${asUrl}/_ref/device-exporters/${encodeURIComponent(device.device_id)}/ingest-batches`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTemporaryPostgres(fn) { - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_ingest_oracle_${process.pid}_${Date.now()}_${unique}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await fn(databaseUrl(DEDICATED_POSTGRES_URL, database)); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + fn, + ); } async function withBackend(kind, fn) { diff --git a/reference-implementation/test/helpers/postgres-temp-database.js b/reference-implementation/test/helpers/postgres-temp-database.js new file mode 100644 index 000000000..24759867f --- /dev/null +++ b/reference-implementation/test/helpers/postgres-temp-database.js @@ -0,0 +1,84 @@ +import pg from 'pg'; + +const { Pool } = pg; + +function adminUrl(connectionString) { + const url = new URL(connectionString); + url.pathname = '/postgres'; + return url.toString(); +} + +function databaseUrl(connectionString, databaseName) { + const url = new URL(connectionString); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +function quotedIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} + +/** + * Run a callback against a disposable database on a shared Postgres cluster. + * + * `DROP DATABASE ... WITH (FORCE)` is deliberate: test failures or a missed + * pool shutdown must not turn into a durable database leak on the proof + * cluster. The repository's Postgres test image is pg16, which supports it. + */ +export async function withTemporaryPostgresDatabase( + { connectionString, databaseName, closeConnections }, + callback, +) { + const admin = new Pool({ connectionString: adminUrl(connectionString) }); + const database = quotedIdentifier(databaseName); + let created = false; + let result; + let operationError; + + try { + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + await admin.query(`CREATE DATABASE ${database}`); + created = true; + result = await callback(databaseUrl(connectionString, databaseName)); + } catch (error) { + operationError = error; + } + + const cleanupErrors = []; + + if (created) { + if (closeConnections) { + try { + await closeConnections(); + } catch (error) { + cleanupErrors.push(error); + } + } + + try { + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + } catch (error) { + cleanupErrors.push(error); + } + } + + try { + await admin.end(); + } catch (error) { + cleanupErrors.push(error); + } + + if (operationError && cleanupErrors.length > 0) { + throw new AggregateError( + [operationError, ...cleanupErrors], + `temporary Postgres database operation and cleanup both failed for ${databaseName}`, + ); + } + if (operationError) throw operationError; + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, `could not clean up temporary Postgres database ${databaseName}`); + } + + return result; +} diff --git a/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js b/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js index 506ccd54f..89ac0bb58 100644 --- a/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js +++ b/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js @@ -31,7 +31,7 @@ * pagination loop runs and issues real `records` queries. * * Target for local runs: - * docker run -d --name pg-pilot -p 55463:5432 \ + * docker run --rm -d --name pg-pilot -p 55463:5432 \ * -e POSTGRES_USER=pdpp -e POSTGRES_PASSWORD=pdpp \ * -e POSTGRES_DB=pdpp_pilot \ * pgvector/pgvector:pg16 diff --git a/reference-implementation/test/postgres-record-index-bootstrap.test.js b/reference-implementation/test/postgres-record-index-bootstrap.test.js index fc6e6bd86..8150722fe 100644 --- a/reference-implementation/test/postgres-record-index-bootstrap.test.js +++ b/reference-implementation/test/postgres-record-index-bootstrap.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_boot_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js b/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js index 9c0016a73..fac85b346 100644 --- a/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js +++ b/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_idem_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-record-index-repair-oracle.test.js b/reference-implementation/test/postgres-record-index-repair-oracle.test.js index 2fc820b6d..aa34d4872 100644 --- a/reference-implementation/test/postgres-record-index-repair-oracle.test.js +++ b/reference-implementation/test/postgres-record-index-repair-oracle.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_repair_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-temp-database-helper.test.js b/reference-implementation/test/postgres-temp-database-helper.test.js new file mode 100644 index 000000000..b872f75ab --- /dev/null +++ b/reference-implementation/test/postgres-temp-database-helper.test.js @@ -0,0 +1,121 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import pg from 'pg'; + +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; + +const { Pool } = pg; +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; + +function adminUrl(connectionString) { + const url = new URL(connectionString); + url.pathname = '/postgres'; + return url.toString(); +} + +async function assertDatabaseDropped(databaseName) { + const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); + try { + const result = await admin.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [databaseName], + ); + assert.equal(result.rowCount, 0); + } finally { + await admin.end(); + } +} + +function databaseName(suffix) { + return `pdpp_temp_helper_${suffix}_${process.pid}_${Date.now()}`; +} + +if (!POSTGRES_URL) { + test('temporary Postgres database helper (skipped: PDPP_TEST_POSTGRES_URL unset)', { skip: true }, () => {}); +} else { + test('temporary Postgres database helper closes connections before dropping the database', async () => { + const temporaryDatabase = databaseName('close'); + let temporaryPool; + let closed = false; + + await withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { + await temporaryPool.end(); + closed = true; + }, + }, + async (connectionString) => { + temporaryPool = new Pool({ connectionString }); + const result = await temporaryPool.query('SELECT current_database() AS database_name'); + assert.equal(result.rows[0]?.database_name, temporaryDatabase); + }, + ); + + assert.equal(closed, true); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper preserves a callback failure', async () => { + const temporaryDatabase = databaseName('callback'); + const callbackError = new Error('callback failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: temporaryDatabase }, + async () => { throw callbackError; }, + ), + (error) => error === callbackError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper reports a cleanup failure after dropping the database', async () => { + const temporaryDatabase = databaseName('cleanup'); + const cleanupError = new Error('cleanup failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { throw cleanupError; }, + }, + async () => {}, + ), + (error) => error === cleanupError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper preserves callback and cleanup failures', async () => { + const temporaryDatabase = databaseName('combined'); + const callbackError = new Error('callback failed'); + const cleanupError = new Error('cleanup failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { throw cleanupError; }, + }, + async () => { throw callbackError; }, + ), + (error) => error instanceof AggregateError + && error.errors.length === 2 + && error.errors[0] === callbackError + && error.errors[1] === cleanupError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); +} diff --git a/reference-implementation/test/spine-source-boot-backfill.test.js b/reference-implementation/test/spine-source-boot-backfill.test.js index 1f879bb53..675bb3ce4 100644 --- a/reference-implementation/test/spine-source-boot-backfill.test.js +++ b/reference-implementation/test/spine-source-boot-backfill.test.js @@ -18,8 +18,6 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import pg from 'pg'; - import { closeDb, getDb, initDb } from '../server/db.js'; import { initPostgresStorage, @@ -27,8 +25,8 @@ import { getPostgresPool, } from '../server/postgres-storage.js'; import { mergeEventRows, postgresListSpineCorrelations } from '../lib/postgres-spine.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; // A deterministic temp DB name per file run. Date.now/Math.random are fine in @@ -39,47 +37,11 @@ function tempDbName() { return `pdpp_spine_boot_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - // Terminate stragglers, then drop. - try { - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } // Insert a minimal spine_events row with NULL source columns. `actorType` diff --git a/reference-implementation/test/storage-mode-startup-boundary.test.js b/reference-implementation/test/storage-mode-startup-boundary.test.js index aaf8d7ef4..a4d997e5e 100644 --- a/reference-implementation/test/storage-mode-startup-boundary.test.js +++ b/reference-implementation/test/storage-mode-startup-boundary.test.js @@ -31,14 +31,13 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import pg from 'pg'; - import { getRegisteredClient } from '../server/auth.js'; import { closeDb } from '../server/db.js'; import { closePostgresStorage, getStorageBackendKind, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; import { startServer } from '../server/index.js'; const SEED_CLIENT = { @@ -48,7 +47,6 @@ const SEED_CLIENT = { token_endpoint_auth_method: 'none', }; -const { Pool } = pg; let tempPostgresCounter = 0; function tempPostgresDbName() { @@ -56,42 +54,11 @@ function tempPostgresDbName() { return `pdpp_storage_boundary_${process.pid}_${tempPostgresCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempPostgresDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempPostgresDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - await fn(dbUrl(POSTGRES_URL, name)); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempPostgresDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function closeStartedServer(server) { diff --git a/scripts/docker-neko-dynamic-allocator-smoke-config.mjs b/scripts/docker-neko-dynamic-allocator-smoke-config.mjs new file mode 100644 index 000000000..6ec6eb4da --- /dev/null +++ b/scripts/docker-neko-dynamic-allocator-smoke-config.mjs @@ -0,0 +1,44 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const DEPLOYMENT_LABEL = 'org.pdpp.reference.neko.deployment_id'; +const SURFACE_LABEL = 'org.pdpp.reference.neko.surface_id'; + +function runScoped(base, runId) { + if (!base || /[\r\n]/.test(base)) throw new Error('dynamic n.eko smoke identity bases must be non-empty single-line strings'); + return `${base.replace(/-+$/, '')}-${runId}`; +} + +export function deriveDynamicNekoSmokeConfig(env, runId) { + if (!/^[a-z0-9-]+$/.test(runId)) throw new Error('dynamic n.eko smoke run id must contain only lowercase letters, digits, and hyphens'); + if (env.PDPP_NEKO_WEBRTC_HOST_PORT_START || env.PDPP_NEKO_WEBRTC_HOST_PORT_END) { + throw new Error( + 'PDPP_NEKO_WEBRTC_HOST_PORT_START and PDPP_NEKO_WEBRTC_HOST_PORT_END must be unset: the smoke reserves a per-run port pair', + ); + } + + const projectName = runScoped(env.PDPP_NEKO_DYNAMIC_SMOKE_PROJECT_NAME ?? 'pdppdynsmoke', runId); + const profileRoot = runScoped(env.PDPP_NEKO_PROFILE_STORAGE_ROOT ?? '/tmp/pdpp-neko-profiles-smoke', runId); + const deploymentId = env.PDPP_NEKO_DEPLOYMENT_ID + ? runScoped(env.PDPP_NEKO_DEPLOYMENT_ID, runId) + : projectName; + const surfaceA = `dynamic-smoke-${runId}-a`; + const surfaceB = `dynamic-smoke-${runId}-b`; + + return { + projectName, + profileRoot, + deploymentId, + surfaceA, + surfaceB, + cleanupFilters: (surfaceId) => [ + `label=${DEPLOYMENT_LABEL}=${deploymentId}`, + `label=${SURFACE_LABEL}=${surfaceId}`, + ], + }; +} + +if (import.meta.main) { + const config = deriveDynamicNekoSmokeConfig(process.env, process.argv[2]); + process.stdout.write([config.projectName, config.profileRoot, config.deploymentId, config.surfaceA, config.surfaceB].join('\n')); +} diff --git a/scripts/docker-neko-dynamic-allocator-smoke.sh b/scripts/docker-neko-dynamic-allocator-smoke.sh index 778d1e906..683378d4d 100755 --- a/scripts/docker-neko-dynamic-allocator-smoke.sh +++ b/scripts/docker-neko-dynamic-allocator-smoke.sh @@ -9,20 +9,53 @@ if [[ "${PDPP_DOCKER_DYNAMIC_NEKO_ALLOCATOR_SMOKE:-}" != "1" ]]; then exit 0 fi -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdppdynsmoke}" -PROFILE_ROOT="${PDPP_NEKO_PROFILE_STORAGE_ROOT:-/tmp/pdpp-neko-profiles-smoke}" -HOST_PORT_START="${PDPP_NEKO_WEBRTC_HOST_PORT_START:-59101}" -HOST_PORT_END="${PDPP_NEKO_WEBRTC_HOST_PORT_END:-59102}" -LABEL_OWNER="org.pdpp.reference.neko.owner=pdpp-reference" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOKE_RUN_ID_BASE="${PDPP_NEKO_DYNAMIC_SMOKE_RUN_ID:-smoke}" +SMOKE_RUN_ID="${SMOKE_RUN_ID_BASE}-$(node -e "console.log(require('node:crypto').randomBytes(6).toString('hex'))")" +SMOKE_CONFIG_OUTPUT="$(node "$SCRIPT_DIR/docker-neko-dynamic-allocator-smoke-config.mjs" "$SMOKE_RUN_ID")" +mapfile -t SMOKE_CONFIG <<< "$SMOKE_CONFIG_OUTPUT" +if (( ${#SMOKE_CONFIG[@]} != 5 )); then + echo "dynamic n.eko allocator Docker smoke failed: invalid derived configuration" >&2 + exit 1 +fi +PROJECT_NAME="${SMOKE_CONFIG[0]}" +PROFILE_ROOT="${SMOKE_CONFIG[1]}" +DEPLOYMENT_ID="${SMOKE_CONFIG[2]}" +SMOKE_SURFACE_A="${SMOKE_CONFIG[3]}" +SMOKE_SURFACE_B="${SMOKE_CONFIG[4]}" +DEPLOYMENT_LABEL="org.pdpp.reference.neko.deployment_id" SURFACE_LABEL="org.pdpp.reference.neko.surface_id" -SMOKE_SURFACE_A="dynamic-smoke-surface-a" -SMOKE_SURFACE_B="dynamic-smoke-surface-b" + +allocate_default_port_range() { + local port + for ((port = 55000; port <= 64998; port += 2)); do + if ! ss -H -ltn "sport = :$port" | grep -q . \ + && ! ss -H -lun "sport = :$port" | grep -q . \ + && ! ss -H -ltn "sport = :$((port + 1))" | grep -q . \ + && ! ss -H -lun "sport = :$((port + 1))" | grep -q .; then + HOST_PORT_START="$port" + HOST_PORT_END="$((port + 1))" + return + fi + done + echo "dynamic n.eko allocator Docker smoke failed: no free consecutive WebRTC ports" >&2 + exit 1 +} + +command -v ss >/dev/null 2>&1 || { echo "dynamic n.eko allocator Docker smoke requires ss" >&2; exit 1; } +command -v flock >/dev/null 2>&1 || { echo "dynamic n.eko allocator Docker smoke requires flock" >&2; exit 1; } +exec {PORT_LOCK_FD}>"${PDPP_NEKO_DYNAMIC_SMOKE_PORT_LOCK_FILE:-/tmp/pdpp-neko-dynamic-smoke-ports.lock}" +flock "$PORT_LOCK_FD" +allocate_default_port_range export COMPOSE_PROJECT_NAME="$PROJECT_NAME" export PDPP_NEKO_PROFILE_STORAGE_ROOT="$PROFILE_ROOT" export PDPP_NEKO_WEBRTC_HOST_PORT_START="$HOST_PORT_START" export PDPP_NEKO_WEBRTC_HOST_PORT_END="$HOST_PORT_END" +export PDPP_NEKO_DEPLOYMENT_ID="$DEPLOYMENT_ID" export PDPP_NEKO_ALLOCATOR_PORT="${PDPP_NEKO_ALLOCATOR_PORT:-7331}" +export PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A="$SMOKE_SURFACE_A" +export PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B="$SMOKE_SURFACE_B" export NEKO_PASSWORD="${NEKO_PASSWORD:-pdpp-dynamic-smoke}" export NEKO_USERNAME="${NEKO_USERNAME:-operator}" @@ -32,12 +65,12 @@ cleanup() { set +e local surface ids for surface in "$SMOKE_SURFACE_A" "$SMOKE_SURFACE_B"; do - ids="$(docker ps -aq --filter "label=$LABEL_OWNER" --filter "label=$SURFACE_LABEL=$surface" 2>/dev/null || true)" + ids="$(docker ps -aq --filter "label=$DEPLOYMENT_LABEL=$DEPLOYMENT_ID" --filter "label=$SURFACE_LABEL=$surface" 2>/dev/null || true)" if [[ -n "$ids" ]]; then docker rm -f $ids >/dev/null 2>&1 || true fi done - "${DC[@]}" down --remove-orphans >/dev/null 2>&1 || true + "${DC[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT @@ -79,14 +112,14 @@ import assert from "node:assert/strict"; const baseUrl = `http://127.0.0.1:${process.env.PDPP_NEKO_ALLOCATOR_PORT ?? "7331"}`; const surfaces = [ { - surface_id: "dynamic-smoke-surface-a", + surface_id: process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A, connector_id: "chatgpt", - profile_key: "pdpp-smoke://profile/a", + profile_key: `pdpp-smoke://${process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A}`, }, { - surface_id: "dynamic-smoke-surface-b", + surface_id: process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B, connector_id: "chatgpt", - profile_key: "pdpp-smoke://profile/b", + profile_key: `pdpp-smoke://${process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B}`, }, ]; @@ -129,7 +162,7 @@ assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.conta assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.host_port)).size, 2); assert.deepEqual( allocated.map((surface) => surface.allocator_metadata.host_port).sort(), - ["59101", "59102"], + [process.env.PDPP_NEKO_WEBRTC_HOST_PORT_START, process.env.PDPP_NEKO_WEBRTC_HOST_PORT_END].sort(), ); assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.profile_path)).size, 2); for (const surface of allocated) { @@ -178,17 +211,17 @@ cleanup trap - EXIT for surface in "$SMOKE_SURFACE_A" "$SMOKE_SURFACE_B"; do - if [[ -n "$(docker ps -aq --filter "label=$LABEL_OWNER" --filter "label=$SURFACE_LABEL=$surface")" ]]; then + if [[ -n "$(docker ps -aq --filter "label=$DEPLOYMENT_LABEL=$DEPLOYMENT_ID" --filter "label=$SURFACE_LABEL=$surface")" ]]; then fail "labeled dynamic n.eko smoke container $surface remains after cleanup" fi done -if docker network ls --filter "label=$LABEL_OWNER" --format '{{.Name}}' | grep -q .; then - fail "labeled dynamic n.eko networks remain after cleanup" -fi - if docker network ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' | grep -q .; then fail "Compose network for $PROJECT_NAME remains after cleanup" fi +if docker volume ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' | grep -q .; then + fail "Compose volumes for $PROJECT_NAME remain after cleanup" +fi + echo "Dynamic n.eko allocator Docker smoke passed for project $PROJECT_NAME." diff --git a/scripts/docker-neko-dynamic-allocator-smoke.test.mjs b/scripts/docker-neko-dynamic-allocator-smoke.test.mjs new file mode 100644 index 000000000..d2584ccbc --- /dev/null +++ b/scripts/docker-neko-dynamic-allocator-smoke.test.mjs @@ -0,0 +1,38 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { deriveDynamicNekoSmokeConfig } from './docker-neko-dynamic-allocator-smoke-config.mjs'; + +test('dynamic n.eko smoke derives disjoint resources from one inherited environment', () => { + const inherited = { + PDPP_NEKO_DYNAMIC_SMOKE_PROJECT_NAME: 'shared-project', + PDPP_NEKO_PROFILE_STORAGE_ROOT: '/shared/profiles', + PDPP_NEKO_DEPLOYMENT_ID: 'shared-deployment', + }; + const first = deriveDynamicNekoSmokeConfig(inherited, 'a1b2c3'); + const second = deriveDynamicNekoSmokeConfig(inherited, 'd4e5f6'); + + for (const field of ['projectName', 'profileRoot', 'deploymentId', 'surfaceA', 'surfaceB']) { + assert.notEqual(first[field], second[field]); + } + assert.deepEqual(first.cleanupFilters(first.surfaceA), [ + 'label=org.pdpp.reference.neko.deployment_id=shared-deployment-a1b2c3', + 'label=org.pdpp.reference.neko.surface_id=dynamic-smoke-a1b2c3-a', + ]); + assert.notDeepEqual(first.cleanupFilters(first.surfaceA), second.cleanupFilters(second.surfaceA)); +}); + +test('dynamic n.eko smoke rejects inherited WebRTC port settings', () => { + assert.throws( + () => deriveDynamicNekoSmokeConfig({ PDPP_NEKO_WEBRTC_HOST_PORT_START: '59101' }, 'a1b2c3'), + /must be unset/, + ); +}); + +test('dynamic n.eko smoke defaults deployment id to its derived project id', () => { + const config = deriveDynamicNekoSmokeConfig({}, 'smoke-a1b2c3'); + assert.equal(config.deploymentId, config.projectName); +}); diff --git a/scripts/docker-smoke.sh b/scripts/docker-smoke.sh index 7f69b202b..21fbd30bc 100755 --- a/scripts/docker-smoke.sh +++ b/scripts/docker-smoke.sh @@ -4,7 +4,7 @@ set -euo pipefail -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdpp-docker-smoke}" +PROJECT_NAME="${PDPP_DOCKER_SMOKE_PROJECT_NAME:-pdpp-docker-smoke}" ORIGIN="${PDPP_REFERENCE_ORIGIN:-http://localhost:3002}" OWNER_PASSWORD="${PDPP_OWNER_PASSWORD:-$(node -e "console.log(require('node:crypto').randomBytes(24).toString('base64url'))")}" @@ -15,7 +15,7 @@ export PDPP_DB_PATH="${PDPP_DB_PATH:-/tmp/pdpp-smoke.sqlite}" export PDPP_EMBEDDING_DOWNLOAD_ALLOWED="${PDPP_EMBEDDING_DOWNLOAD_ALLOWED:-0}" cleanup() { - docker compose down --remove-orphans >/dev/null + docker compose down --volumes --remove-orphans >/dev/null } trap cleanup EXIT diff --git a/scripts/railway-sqlite-restart-smoke.sh b/scripts/railway-sqlite-restart-smoke.sh index 5b3724f03..4b0e9ab77 100755 --- a/scripts/railway-sqlite-restart-smoke.sh +++ b/scripts/railway-sqlite-restart-smoke.sh @@ -47,7 +47,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdpp-sqlite-restart-smoke}" +PROJECT_NAME="${PDPP_RAILWAY_SQLITE_SMOKE_PROJECT_NAME:-pdpp-sqlite-restart-smoke}" ORIGIN="${PDPP_REFERENCE_ORIGIN:-http://localhost:3002}" OWNER_PASSWORD="${PDPP_OWNER_PASSWORD:-$(node -e "console.log(require('node:crypto').randomBytes(24).toString('base64url'))")}" @@ -78,7 +78,7 @@ NODE cd "$REPO_ROOT" cleanup() { - docker compose down --remove-orphans >/dev/null 2>&1 || true + docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT