diff --git a/.gitignore b/.gitignore index 9bce6ceaf..33786a672 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,3 @@ apps/api/deploy/ # Build-time evidence artifacts (generated) build-artifacts/ - diff --git a/.snyk b/.snyk index 913e749cf..f9cb17e99 100644 --- a/.snyk +++ b/.snyk @@ -91,13 +91,13 @@ ignore: reason: 'Transitive dependency in @docusaurus/preset-classic; not exploitable in current usage.' expires: '2026-06-18T00:00:00.000Z' created: '2026-05-18T11:04:00.000Z' + 'SNYK-JS-POSTCSSSELECTORPARSER-16873882': + - '* > postcss-selector-parser': + reason: 'Transitive dependency in @docusaurus/core build pipeline; no upgrade or patch available from upstream. Not exploitable in current usage (build-time CSS processing only).' + expires: '2026-08-26T00:00:00.000Z' + created: '2026-05-26T00:00:00.000Z' 'SNYK-JS-OPENTELEMETRYEXPORTERPROMETHEUS-16758050': - '* > @opentelemetry/exporter-prometheus@0.57.2': reason: 'Requires upgrade of @opentelemetry/sdk-node to 0.217.0, which has type errors that break compilation. Created task to upgrade OTEL service to 2.x and resolve vulnerability that way.' expires: '2026-07-28T00:00:00.000Z' created: '2026-06-01T10:00:00.000Z' - 'SNYK-JS-POSTCSSSELECTORPARSER-16873882': - - '* > postcss-selector-parser': - reason: 'Transitive dependency in Docusaurus CSS optimization/build tooling; Snyk reports no fixed version for postcss-selector-parser yet. Not exploitable at runtime because docs CSS is repository-controlled and processed at build time.' - expires: '2026-07-28T00:00:00.000Z' - created: '2026-05-27T00:00:00.000Z' diff --git a/apps/api/local-settings.e2e.json b/apps/api/local-settings.e2e.json new file mode 100644 index 000000000..174c23534 --- /dev/null +++ b/apps/api/local-settings.e2e.json @@ -0,0 +1,29 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "node", + "NODE_ENV": "development", + "languageWorkers__node__arguments": "", + "AZURE_STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "ACCOUNT_PORTAL_OIDC_AUDIENCE": "mock-client", + "ACCOUNT_PORTAL_OIDC_ENDPOINT": "https://mock-auth.ownercommunity.localhost:1355/community/.well-known/jwks.json", + "ACCOUNT_PORTAL_OIDC_ISSUER": "https://mock-auth.ownercommunity.localhost:1355/community", + "ACCOUNT_PORTAL_OIDC_IGNORE_ISSUER": true, + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "CONFIG_VERSION": "3.0", + "COSMOSDB_CONNECTION_STRING": "mongodb://127.0.0.1:50000/owner-community?replicaSet=globaldb", + "COSMOSDB_DBNAME": "owner-community", + "STAFF_PORTAL_OIDC_AUDIENCE": "mock-client", + "STAFF_PORTAL_OIDC_ENDPOINT": "https://mock-auth.ownercommunity.localhost:1355/staff/.well-known/jwks.json", + "STAFF_PORTAL_OIDC_ISSUER": "https://mock-auth.ownercommunity.localhost:1355/staff", + "STAFF_PORTAL_OIDC_IGNORE_ISSUER": true, + "STORAGE_ACCOUNT_NAME": "devstoreaccount1", + "STORAGE_ACCOUNT_KEY": "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + }, + "ConnectionStrings": {}, + "Host": { + "LocalHttpPort": 7071, + "CORS": "*" + } +} diff --git a/apps/api/package.json b/apps/api/package.json index fce77a1f4..f4d026525 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,8 @@ "build": "tsgo --build && rolldown -c rolldown.config.ts", "predev": "pnpm run prepare:deploy && pnpm run sync-local-settings", "dev": "pnpm exec portless data-access.ownercommunity.localhost --force node start-dev.mjs", + "predev:worktree": "pnpm run prepare:deploy && pnpm run sync-local-settings", + "dev:worktree": "pnpm exec portless data-access.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", "prepare:deploy": "cellix-prepare-func-deploy", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", @@ -21,8 +23,8 @@ "clean": "rimraf dist deploy", "prestart": "pnpm run prepare:deploy && pnpm run sync-local-settings", "start": "func start --typescript --script-root deploy/", - "sync-local-settings": "node -e \"const fs=require('node:fs'); fs.mkdirSync('deploy',{recursive:true}); if (fs.existsSync('local.settings.json')) fs.copyFileSync('local.settings.json','deploy/local.settings.json');\"", - "azurite": "azurite-blob --silent --location ../../__blobstorage__ & azurite-queue --silent --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__" + "sync-local-settings": "node scripts/sync-local-settings.mjs", + "azurite": "node start-azurite.mjs" }, "dependencies": { "@azure/functions": "catalog:", @@ -31,8 +33,8 @@ "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", "@ocom/event-handler": "workspace:*", - "@ocom/graphql-handler": "workspace:*", "@ocom/graphql": "workspace:*", + "@ocom/graphql-handler": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/rest": "workspace:*", "@ocom/service-apollo-server": "workspace:*", @@ -47,6 +49,7 @@ "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", "@vitest/coverage-istanbul": "catalog:", + "azurite": "^3.35.0", "rimraf": "catalog:", "rolldown": "1.0.0-beta.55", "typescript": "catalog:", diff --git a/apps/api/scripts/sync-local-settings.mjs b/apps/api/scripts/sync-local-settings.mjs new file mode 100644 index 000000000..d9b3a7acc --- /dev/null +++ b/apps/api/scripts/sync-local-settings.mjs @@ -0,0 +1,63 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildPortlessUrl, getHostnames } from '../../../scripts/local-dev/portless-hostnames.mjs'; +import { getAzuriteConnectionString } from '../../../scripts/local-dev/worktree-ports.mjs'; + +const apiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const mode = process.argv[2] ?? (isE2E() ? 'e2e' : undefined); +const localSettingsPath = path.join(apiDir, 'local.settings.json'); +const e2eLocalSettingsPath = path.join(apiDir, 'local-settings.e2e.json'); +const targetPath = path.join(apiDir, 'deploy', 'local.settings.json'); + +mkdirSync(path.dirname(targetPath), { recursive: true }); + +if (!mode) { + if (existsSync(localSettingsPath)) { + copyFileSync(localSettingsPath, targetPath); + } + process.exit(0); +} + +if (mode !== 'e2e') { + throw new Error('[sync-local-settings] Invalid mode: expected one of e2e'); +} + +if (!existsSync(e2eLocalSettingsPath)) { + throw new Error(`[sync-local-settings] Missing local settings for mode "e2e": ${e2eLocalSettingsPath}`); +} + +const settings = JSON.parse(readFileSync(e2eLocalSettingsPath, 'utf-8')); +applyE2EOverrides(settings); +writeFileSync(targetPath, `${JSON.stringify(settings, null, '\t')}\n`); + +function applyE2EOverrides(settings) { + const values = { ...(settings.Values ?? {}) }; + + // Worktree-scoped overrides: when WORKTREE_NAME is set the proxy hostnames + // and Azurite ports are scoped to that worktree, so we rewrite the URLs and + // connection strings here. Without WORKTREE_NAME the committed JSON values + // already match the default hostnames, so we leave them alone. + if (process.env.WORKTREE_NAME) { + const hostnames = getHostnames(); + values.ACCOUNT_PORTAL_OIDC_ISSUER = buildPortlessUrl(hostnames.mockAuth, '/community'); + values.ACCOUNT_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); + values.STAFF_PORTAL_OIDC_ISSUER = buildPortlessUrl(hostnames.mockAuth, '/staff'); + values.STAFF_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); + const azurite = getAzuriteConnectionString(values); + values.AZURE_STORAGE_CONNECTION_STRING = azurite; + values.AzureWebJobsStorage = azurite; + } + + // Runtime-only injection: the e2e harness spawns MongoMemoryServer on a + // random port and passes the connection string through process.env. + if (process.env.COSMOSDB_CONNECTION_STRING) { + values.COSMOSDB_CONNECTION_STRING = process.env.COSMOSDB_CONNECTION_STRING; + } + + settings.Values = values; +} + +function isE2E() { + return ['1', 'true', 'yes'].includes((process.env.E2E ?? '').toLowerCase()); +} diff --git a/apps/api/start-azurite.mjs b/apps/api/start-azurite.mjs new file mode 100644 index 000000000..8cb110b31 --- /dev/null +++ b/apps/api/start-azurite.mjs @@ -0,0 +1,47 @@ +import { spawn } from 'node:child_process'; +import { isGracefulInterruptExit } from '../../scripts/local-dev/dev-process-exit.mjs'; +import { getAzuritePorts } from '../../scripts/local-dev/worktree-ports.mjs'; + +const ports = getAzuritePorts(); +const worktreeName = process.env.WORKTREE_NAME ?? ''; +const storageSuffix = worktreeName ? `-${worktreeName}` : ''; + +const blobDir = `../../__blobstorage__${storageSuffix}`; +const queueDir = `../../__queuestorage__${storageSuffix}`; +const tableDir = `../../__tablestorage__${storageSuffix}`; + +const procSpecs = [ + ['azurite-blob', ['--silent', '--blobPort', String(ports.blob), '--location', blobDir]], + ['azurite-queue', ['--silent', '--queuePort', String(ports.queue), '--location', queueDir]], + ['azurite-table', ['--silent', '--tablePort', String(ports.table), '--location', tableDir]], +]; +const procs = procSpecs.map(([command, args]) => { + const proc = spawn(command, args, { stdio: 'inherit' }); + proc.on('error', (error) => { + console.error(`[azurite] failed to start ${command}: ${error.message}`); + for (const p of procs) p.kill(); + process.exit(1); + }); + return proc; +}); + +console.log(`[azurite] started (blob=${ports.blob}, queue=${ports.queue}, table=${ports.table})`); + +let exited = 0; +for (const proc of procs) { + proc.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + if (++exited === procs.length) process.exit(0); + return; + } + console.error(`[azurite] process exited unexpectedly: code=${code} signal=${signal}`); + for (const p of procs) p.kill(); + process.exit(code ?? 1); + }); +} +process.on('SIGINT', () => { + for (const p of procs) p.kill('SIGINT'); +}); +process.on('SIGTERM', () => { + for (const p of procs) p.kill('SIGTERM'); +}); diff --git a/apps/api/start-dev.mjs b/apps/api/start-dev.mjs index edcb6a350..17cb96ad8 100644 --- a/apps/api/start-dev.mjs +++ b/apps/api/start-dev.mjs @@ -1,7 +1,9 @@ import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; -import { isGracefulInterruptExit } from '../../build-pipeline/scripts/dev-process-exit.mjs'; +import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; +import { buildPortlessUrl, getHostnames } from '../../scripts/local-dev/portless-hostnames.mjs'; +import { getAzuriteConnectionString, getMongoConnectionString } from '../../scripts/local-dev/worktree-ports.mjs'; const envPort = process.env.PORT; @@ -18,6 +20,22 @@ const childEnv = { NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --use-system-ca`.trim(), }; +// Only inject worktree-scoped overrides when running in worktree mode. +// When WORKTREE_NAME is absent, local.settings.json remains the source of truth. +// Use `??=` so callers can override any individual value via process.env. +if (process.env.WORKTREE_NAME) { + const hostnames = getHostnames(); + childEnv.ACCOUNT_PORTAL_OIDC_ISSUER ??= buildPortlessUrl(hostnames.mockAuth, '/community'); + childEnv.ACCOUNT_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); + childEnv.STAFF_PORTAL_OIDC_ISSUER ??= buildPortlessUrl(hostnames.mockAuth, '/staff'); + childEnv.STAFF_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); + childEnv.COSMOSDB_CONNECTION_STRING ??= getMongoConnectionString(); + childEnv.AZURE_STORAGE_CONNECTION_STRING ??= getAzuriteConnectionString(); + childEnv.AzureWebJobsStorage ??= getAzuriteConnectionString(); + // Disable the Node.js inspector — port 5858 is already used by the primary worktree. + childEnv.languageWorkers__node__arguments ??= ''; +} + // `--cors '*'` matches Host.CORS in local.settings.json but does not depend on // that file existing — local.settings.json is gitignored, so CI has no CORS // allowance otherwise and the UI's cross-origin GraphQL requests are blocked. @@ -26,11 +44,4 @@ const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/' env: childEnv, }); -child.on('exit', (code, signal) => { - // Turbo sends signals to interrupt persistent tasks; treat those as graceful exits. - if (isGracefulInterruptExit(signal, code)) { - process.exitCode = 0; - return; - } - process.exitCode = code ?? 1; -}); +forwardChildExit(child); diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 25ef0ca81..75ff6794b 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -4,13 +4,18 @@ "build": { "cache": true, "dependsOn": ["^build", "//#gen"], - "inputs": ["$TURBO_EXTENDS$", "rolldown.config.ts", "host.json", "$TURBO_ROOT$/build-pipeline/scripts/**"], + "inputs": ["$TURBO_EXTENDS$", "rolldown.config.ts", "host.json", "$TURBO_ROOT$/scripts/local-dev/**"], "outputs": ["$TURBO_EXTENDS$", "deploy/**"] }, "dev": { "dependsOn": ["build"], "interruptible": true, "inputs": [] + }, + "dev:worktree": { + "dependsOn": ["build"], + "interruptible": true, + "inputs": [] } } } diff --git a/apps/docs/package.json b/apps/docs/package.json index 64b40e9f8..9e8348ac1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,6 +5,7 @@ "scripts": { "docusaurus": "docusaurus", "dev": "pnpm exec portless docs.ownercommunity.localhost --force node start-dev.mjs", + "dev:worktree": "pnpm exec portless docs.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", "start": "docusaurus start --port 3001", "build": "docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/apps/docs/start-dev.mjs b/apps/docs/start-dev.mjs index cb09162a8..8180032bb 100644 --- a/apps/docs/start-dev.mjs +++ b/apps/docs/start-dev.mjs @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { isGracefulInterruptExit } from '../../build-pipeline/scripts/dev-process-exit.mjs'; +import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; const port = process.env.PORT ?? '3001'; @@ -7,11 +7,4 @@ const child = spawn('docusaurus', ['start', '--port', port, '--host', '127.0.0.1 stdio: 'inherit', }); -child.on('exit', (code, signal) => { - // Turbo sends signals to interrupt persistent tasks; treat those as graceful exits. - if (isGracefulInterruptExit(signal, code)) { - process.exitCode = 0; - return; - } - process.exitCode = code ?? 1; -}); +forwardChildExit(child); diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json index 94418f57b..304f1bb57 100644 --- a/apps/docs/turbo.json +++ b/apps/docs/turbo.json @@ -7,6 +7,12 @@ "interruptible": false, "inputs": [".env", "package.json", "start-dev.mjs", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] }, + "dev:worktree": { + "dependsOn": [], + "persistent": true, + "interruptible": false, + "inputs": [".env", "package.json", "start-dev.mjs", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] + }, "test": { "inputs": ["$TURBO_EXTENDS$", "!docs/**", "!blog/**", "!static/**"] }, diff --git a/apps/server-mongodb-memory-mock/package.json b/apps/server-mongodb-memory-mock/package.json index d34485afc..ed4a774d3 100644 --- a/apps/server-mongodb-memory-mock/package.json +++ b/apps/server-mongodb-memory-mock/package.json @@ -11,7 +11,8 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "dev:worktree": "node start-mongo.mjs" }, "dependencies": { "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", diff --git a/apps/server-mongodb-memory-mock/start-mongo.mjs b/apps/server-mongodb-memory-mock/start-mongo.mjs new file mode 100644 index 000000000..cead8110f --- /dev/null +++ b/apps/server-mongodb-memory-mock/start-mongo.mjs @@ -0,0 +1,12 @@ +import { spawn } from 'node:child_process'; +import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; +import { getMongoPort } from '../../scripts/local-dev/worktree-ports.mjs'; + +const MONGO_PORT = getMongoPort(); + +const child = spawn('tsx', ['src/index.ts'], { + stdio: 'inherit', + env: { ...process.env, PORT: String(MONGO_PORT) }, +}); + +forwardChildExit(child); diff --git a/apps/server-mongodb-memory-mock/turbo.json b/apps/server-mongodb-memory-mock/turbo.json index 01cb45a99..5aea8cad2 100644 --- a/apps/server-mongodb-memory-mock/turbo.json +++ b/apps/server-mongodb-memory-mock/turbo.json @@ -7,6 +7,12 @@ "persistent": true, "interruptible": true, "inputs": [] + }, + "dev:worktree": { + "dependsOn": ["build"], + "persistent": true, + "interruptible": true, + "inputs": [] } } } diff --git a/apps/server-oauth2-mock/package.json b/apps/server-oauth2-mock/package.json index c371c0518..7201d84b9 100644 --- a/apps/server-oauth2-mock/package.json +++ b/apps/server-oauth2-mock/package.json @@ -12,6 +12,7 @@ "format:check": "biome format .", "start": "node dist/index.js", "dev": "pnpm exec portless mock-auth.ownercommunity.localhost --force tsx src/index.ts", + "dev:worktree": "pnpm exec portless mock-auth.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest" diff --git a/apps/server-oauth2-mock/src/portal-discovery.ts b/apps/server-oauth2-mock/src/portal-discovery.ts index 43b3329c5..34c63cc87 100644 --- a/apps/server-oauth2-mock/src/portal-discovery.ts +++ b/apps/server-oauth2-mock/src/portal-discovery.ts @@ -119,16 +119,17 @@ function buildPortalFromConfig(config: MockOidcConfig, parsedEnv: Record Boolean(signal && INTERRUPT_SIGNALS.has(signal)); - -export const isInterruptExitCode = (code) => Number.isInteger(code) && INTERRUPT_EXIT_CODES.has(code); - -export const isGracefulInterruptExit = (signal, code) => isInterruptSignal(signal) || isInterruptExitCode(code); diff --git a/knip.json b/knip.json index 035e1f57f..130ffcb7f 100644 --- a/knip.json +++ b/knip.json @@ -2,8 +2,9 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { "apps/api": { - "entry": ["src/index.ts"], - "project": ["src/**/*.ts"] + "entry": ["src/index.ts", "start-*.mjs"], + "project": ["src/**/*.ts", "*.mjs"], + "ignoreDependencies": ["azurite"] }, "apps/ui-community": { "entry": ["src/main.tsx"], @@ -97,7 +98,21 @@ } }, "ignoreWorkspaces": ["packages/cellix/config-typescript"], - "ignore": ["build-pipeline/scripts/**", "**/fixtures/**", "**/*.test.ts", "**/*.spec.ts", "**/*.stories.tsx", "**/dist/**", "**/coverage/**", "**/__tests__/**", "**/tests/**", ".agents/**", ".github/**", "portless.config.cjs"], + "ignore": [ + "build-pipeline/scripts/**", + "scripts/local-dev/**", + "**/fixtures/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.stories.tsx", + "**/dist/**", + "**/coverage/**", + "**/__tests__/**", + "**/tests/**", + ".agents/**", + ".github/**", + "portless.config.cjs" + ], "ignoreIssues": { "codegen.yml": ["unlisted"] }, diff --git a/package.json b/package.json index b5fb11076..8d13b475f 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test": "turbo run test", "lint": "turbo run lint", "dev": "pnpm proxy:stop && pnpm proxy:start && turbo watch azurite dev --filter='./apps/*' --filter='./packages/*'", + "dev:worktree": "WORKTREE_NAME=$(basename $PWD) pnpm proxy:ensure && WORKTREE_NAME=$(basename $PWD) turbo watch azurite dev:worktree --filter='./apps/*' --filter='./packages/*'", "start": "turbo run build && concurrently --kill-others-on-fail \"pnpm run start:api\" \"pnpm run start:ui-community\"", "proxy:stop": "pnpm exec portless proxy stop || true", "proxy:start": "pnpm exec portless proxy start --https -p 1355", + "proxy:ensure": "pnpm exec portless proxy start --https -p 1355 || true", "format": "turbo run format", "format:check": "turbo run format:check", "format:staged": "biome check --write --staged --no-errors-on-unmatched", @@ -32,8 +34,9 @@ "test:coverage:affected": "turbo run test:coverage test:coverage:acceptance --affected", "test:coverage:merge": "pnpm run test:coverage && pnpm run merge-lcov-reports", "test:e2e": "turbo run test:e2e --filter=@ocom-verification/e2e-tests", + "test:e2e:worktree": "WORKTREE_NAME=$(basename $PWD) turbo run test:e2e --filter=@ocom-verification/e2e-tests", "test:e2e:ci": "turbo run test:e2e:ci --filter=@ocom-verification/e2e-tests", - "test:acceptance": "turbo run test:acceptance", + "test:acceptance": "turbo run test:acceptance --filter=@ocom-verification/acceptance-api --filter=@ocom-verification/acceptance-ui", "merge-lcov-reports": "node build-pipeline/scripts/merge-coverage.js", "test:integration": "turbo run test:integration", "test:serenity": "turbo run test:serenity", @@ -48,7 +51,7 @@ "sonar:pr": "export PR_NUMBER=$(node build-pipeline/scripts/get-pr-number.cjs) && sonar-scanner -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$(git branch --show-current) -Dsonar.pullrequest.base=main", "sonar:pr-windows": "for /f %i in ('node build-pipeline/scripts/get-pr-number.cjs') do set PR_NUMBER=%i && sonar-scanner -Dsonar.pullrequest.key=%PR_NUMBER% -Dsonar.pullrequest.branch=%BRANCH_NAME% -Dsonar.pullrequest.base=main", "check-sonar": "node build-pipeline/scripts/check-sonar-quality-gate.cjs", - "verify": "pnpm run format:check && pnpm run test:arch && pnpm run test:coverage:merge && pnpm run test:e2e && pnpm run knip && pnpm run audit && pnpm run snyk && pnpm run sonar:pr && pnpm run check-sonar", + "verify": "pnpm run format:check && pnpm run test:arch && pnpm run test:coverage:merge && pnpm run test:e2e:worktree && pnpm run knip && pnpm run audit && pnpm run snyk && pnpm run sonar:pr && pnpm run check-sonar", "knip": "knip", "snyk": "pnpm run snyk:test && pnpm run snyk:code", "snyk:report": "pnpm run snyk:monitor && pnpm run snyk:code:report", @@ -79,7 +82,6 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@vitest/coverage-istanbul": "catalog:", - "azurite": "^3.35.0", "chrome-devtools-mcp": "^0.21.0", "concurrently": "^9.1.2", "husky": "^9.1.7", diff --git a/packages/cellix/archunit-tests/package.json b/packages/cellix/archunit-tests/package.json index 3c1e6ba9c..96dcca505 100644 --- a/packages/cellix/archunit-tests/package.json +++ b/packages/cellix/archunit-tests/package.json @@ -43,8 +43,8 @@ }, "scripts": { "prebuild": "biome lint", - "build": "tsc --build", - "watch": "tsc --watch", + "build": "tsgo --build", + "watch": "tsgo --watch", "test": "vitest run", "test:arch": "vitest run", "test:coverage": "pnpm run test", diff --git a/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts b/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts index dffd74aee..aed29c1cf 100644 --- a/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts +++ b/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts @@ -11,7 +11,11 @@ export interface MongoMemoryReplicaSetDisposer { stop: () => Promise; } -export async function startMongoMemoryReplicaSet(config: MongoMemoryReplicaSetConfig): Promise<{ replicaSet: MongoMemoryReplSet; disposer: MongoMemoryReplicaSetDisposer }> { +export async function startMongoMemoryReplicaSet(config: MongoMemoryReplicaSetConfig): Promise<{ + replicaSet: MongoMemoryReplSet; + connectionString: string; + disposer: MongoMemoryReplicaSetDisposer; +}> { console.log('Starting MongoDB Memory Replica Set', { port: config.port, dbName: config.dbName, @@ -43,5 +47,5 @@ export async function startMongoMemoryReplicaSet(config: MongoMemoryReplicaSetCo }, }; - return { replicaSet, disposer }; + return { replicaSet, connectionString: uri, disposer }; } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts new file mode 100644 index 000000000..c9b39119a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts @@ -0,0 +1,5 @@ +export interface HeaderApiNotes { + identityProviderUnreachable: boolean; + signinRedirectInvoked: boolean; + fallbackTriggered: boolean; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts index 8ab66301e..d204f57f9 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -1,11 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, notes } from '@serenity-js/core'; - -interface HeaderApiNotes { - identityProviderUnreachable: boolean; - signinRedirectInvoked: boolean; - fallbackTriggered: boolean; -} +import type { HeaderApiNotes } from '../abilities/header-types.ts'; +import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; let lastActorName = 'Alex'; @@ -32,8 +28,7 @@ Given('the identity provider is unreachable', async () => { When('{word} chooses to sign in', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - const unreachable = await actor.answer(notes().get('identityProviderUnreachable')); - await actor.attemptsTo(notes().set('signinRedirectInvoked', !unreachable), notes().set('fallbackTriggered', unreachable)); + await actor.attemptsTo(ClickHeaderSignIn()); }); Then('{word} is taken to the sign-in flow', async (actorName: string) => { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts new file mode 100644 index 000000000..b0e46f773 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -0,0 +1,14 @@ +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; +import type { HeaderApiNotes } from '../abilities/header-types.ts'; + +export const ClickHeaderSignIn = () => + Task.where( + '#actor chooses to sign in through the authentication API', + new TaskStep('#actor requests the sign-in redirect state', async (serenityActor) => { + const actor = serenityActor as Actor; + const unreachable = await actor.answer(notes().get('identityProviderUnreachable')); + + await actor.attemptsTo(notes().set('signinRedirectInvoked', !unreachable), notes().set('fallbackTriggered', unreachable)); + }) as Activity, + ); diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts index ce5409030..799cf064b 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts @@ -1,10 +1,8 @@ import { GraphQLTestServer, MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; -import { apiSettings } from '@ocom-verification/verification-shared/settings'; import { createMockApplicationServicesFactory } from './application-services/index.ts'; // Shared infrastructure — persists across scenarios within a single test run let mongoDBServer: MongoDBTestServer | undefined; -let mongoSeeded = false; let graphQLServer: GraphQLTestServer | undefined; let apiUrl: string | undefined; @@ -26,27 +24,13 @@ export async function stopAll(): Promise { mongoDBServer = undefined; } apiUrl = undefined; - mongoSeeded = false; } -async function ensureMongoDBServer(options?: { port?: number; dbName?: string }): Promise { +async function ensureMongoDBServer(): Promise { if (mongoDBServer) return mongoDBServer; - const connectionString = options?.port ? apiSettings.cosmosDbConnectionString : ''; - - if (connectionString && (await MongoDBTestServer.isReachable(connectionString))) { - if (!mongoSeeded) { - await MongoDBTestServer.seedData(connectionString, options?.dbName ?? apiSettings.cosmosDbName); - mongoSeeded = true; - } - mongoDBServer = new MongoDBTestServer(); - await mongoDBServer.start(options); - return mongoDBServer; - } - mongoDBServer = new MongoDBTestServer(); - await mongoDBServer.start(options); - mongoSeeded = true; + await mongoDBServer.start({ attachMongoose: true }); return mongoDBServer; } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts index 8b634c058..5b36010c4 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,6 +1,7 @@ import { HomePage, type UiHomePage } from '@ocom-verification/verification-shared/pages'; import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { Interaction } from '@serenity-js/core'; +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { type Activity, Task } from '@serenity-js/core'; async function flushAsync(): Promise { await new Promise((resolve) => { @@ -9,10 +10,13 @@ async function flushAsync(): Promise { } export const ClickHeaderSignIn = (container: HTMLElement) => - Interaction.where('#actor clicks the sign-in button on the home page', async () => { - const adapter = new JsdomPageAdapter(container); - const page: UiHomePage = new HomePage(adapter); + Task.where( + '#actor clicks the sign-in button on the home page', + new TaskStep('#actor clicks the sign-in button', async () => { + const adapter = new JsdomPageAdapter(container); + const page: UiHomePage = new HomePage(adapter); - await page.clickSignIn(); - await flushAsync(); - }); + await page.clickSignIn(); + await flushAsync(); + }) as Activity, + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts index 2c2683b3e..320723c11 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-community.ts @@ -1,6 +1,7 @@ import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { Interaction } from '@serenity-js/core'; +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { type Activity, Task } from '@serenity-js/core'; async function flushAsync(): Promise { await new Promise((resolve) => { @@ -12,12 +13,15 @@ async function flushAsync(): Promise { } export const CreateCommunity = (container: HTMLElement, name: string) => - Interaction.where(`#actor fills community name "${name}" and submits`, async () => { - const adapter = new JsdomPageAdapter(container); - const page: UiCommunityPage = new CommunityPage(adapter); + Task.where( + `#actor fills community name "${name}" and submits`, + new TaskStep(`#actor submits community name "${name}"`, async () => { + const adapter = new JsdomPageAdapter(container); + const page: UiCommunityPage = new CommunityPage(adapter); - await page.fillName(name); - await page.clickCreate(); + await page.fillName(name); + await page.clickCreate(); - await flushAsync(); - }); + await flushAsync(); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index 51912f713..e548d91e5 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -7,5 +7,7 @@ export default { formatOptions: { snippetInterface: 'async-await', }, + // Disable parallel workers — the shared portless proxy and per-worktree port + // scheme make parallel browsers contend for the same hostnames. parallel: 0, }; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts new file mode 100644 index 000000000..b9ad079eb --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts @@ -0,0 +1,7 @@ +export interface HeaderE2ENotes { + signinRedirectInvoked: boolean; + fallbackTriggered: boolean; + postLoginUrl: string; +} + +export type HeaderE2ESite = 'community' | 'staff'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts index ee45019e4..998ec4dcd 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -3,18 +3,12 @@ import { actorCalled, notes } from '@serenity-js/core'; import type { BrowserContext, Page } from 'playwright'; import * as infra from '../../../shared/support/shared-infrastructure.ts'; import type { CellixE2EWorld } from '../../../world.ts'; - -interface HeaderE2ENotes { - signinRedirectInvoked: boolean; - fallbackTriggered: boolean; - postLoginUrl: string; -} - -type Site = 'community' | 'staff'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; +import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; interface HeaderE2EState { actorName: string; - site: Site; + site: HeaderE2ESite; identityProviderUnreachable: boolean; context?: BrowserContext; page?: Page; @@ -29,7 +23,7 @@ function getHeaderState(world: HeaderE2EWorld): HeaderE2EState { return world.__headerState; } -function setHeaderState(world: HeaderE2EWorld, actorName: string, site: Site): HeaderE2EState { +function setHeaderState(world: HeaderE2EWorld, actorName: string, site: HeaderE2ESite): HeaderE2EState { const state: HeaderE2EState = { actorName, site, identityProviderUnreachable: false }; world.__headerState = state; return state; @@ -63,15 +57,10 @@ Given('the identity provider is unreachable', function (this: HeaderE2EWorld) { getHeaderState(this).identityProviderUnreachable = true; }); -// Credentials from apps/ui-{portal}/mock-oidc.users.json -const portalCredentials: Record = { - community: { username: 'test@example.com', password: 'password' }, - staff: { username: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, -}; - When('{word} chooses to sign in', async function (this: HeaderE2EWorld, actorName: string) { const s = getHeaderState(this); s.actorName = actorName; + const actor = actorCalled(actorName); const { browser } = infra.getState(); if (!browser) throw new Error('Browser not launched'); @@ -93,33 +82,7 @@ When('{word} chooses to sign in', async function (this: HeaderE2EWorld, actorNam const page = await context.newPage(); s.page = page; - // Navigate to site root — the unauthenticated home page is visible - await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 }); - - // Click the sign-in button on the home page - const signInButton = page.getByRole('button', { name: /Log In|Sign In/i }); - await signInButton.click(); - - if (s.identityProviderUnreachable) { - // IdP is blocked — the app should handle the error gracefully. - // Wait for error handling to settle, then leave the page open for Then to inspect. - await page.waitForTimeout(2000); - } else { - // Wait for redirect to mock-auth login form - await page.waitForURL((url) => url.hostname.includes('mock-auth'), { timeout: 15_000 }); - - // Complete the login form with portal-specific credentials - const creds = portalCredentials[s.site]; - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', creds.username); - await page.fill('input[name="password"]', creds.password); - await page.click('button[type="submit"]'); - } - - // Wait for the redirect chain to settle back on the portal - await page.waitForURL((url) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'), { timeout: 30_000 }); - await page.waitForLoadState('networkidle'); - } + await actor.attemptsTo(ClickHeaderSignIn(page, s.site, s.identityProviderUnreachable)); }); Then('{word} is taken to the sign-in flow', async function (this: HeaderE2EWorld, actorName: string) { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts new file mode 100644 index 000000000..bdf690de1 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -0,0 +1,46 @@ +import { type E2EHomePage, HomePage } from '@ocom-verification/verification-shared/pages'; +import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; + +const portalCredentials: Record = { + community: { username: 'test@example.com', password: 'password' }, + staff: { username: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, +}; + +const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'); + +export const ClickHeaderSignIn = (page: Page, site: HeaderE2ESite, identityProviderUnreachable: boolean) => + Task.where( + '#actor clicks the sign-in button on the home page', + new TaskStep('#actor clicks the sign-in button on the home page', async (serenityActor) => { + const actor = serenityActor as Actor; + + await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 }); + + const adapter = new PlaywrightPageAdapter(page); + const homePage: E2EHomePage = new HomePage(adapter); + await homePage.clickSignIn(); + + if (identityProviderUnreachable) { + await page.waitForTimeout(2_000); + await actor.attemptsTo(notes().set('fallbackTriggered', true), notes().set('signinRedirectInvoked', false), notes().set('postLoginUrl', page.url())); + return; + } + + await page.waitForURL((url) => url.hostname.includes('mock-auth'), { timeout: 15_000 }); + + const creds = portalCredentials[site]; + if (page.url().includes('/login')) { + await page.fill('input[name="username"]', creds.username); + await page.fill('input[name="password"]', creds.password); + await page.click('button[type="submit"]'); + } + + await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); + await page.waitForLoadState('networkidle'); + await actor.attemptsTo(notes().set('signinRedirectInvoked', true), notes().set('fallbackTriggered', false), notes().set('postLoginUrl', page.url())); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts index f24fb78f4..1f67af90e 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts @@ -1,6 +1,7 @@ import { CommunityPage, type E2ECommunityPage } from '@ocom-verification/verification-shared/pages'; import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { type Activity, type Actor, notes, Task, the } from '@serenity-js/core'; import type { Response } from 'playwright'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; import type { CommunityE2ENotes } from '../abilities/community-types.ts'; @@ -63,83 +64,86 @@ const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null) * Creates a community through the browser UI. */ export const CreateCommunity = (name: string) => - Interaction.where(the`#actor creates community "${name}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - await page.goto('/community/accounts/create-community', { - waitUntil: 'networkidle', - }); - - const adapter = new PlaywrightPageAdapter(page); - const communityPage: E2ECommunityPage = new CommunityPage(adapter); - - await communityPage.fillName(name); - - const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createCommunityOperationName), { timeout: 15_000 }).catch(() => null); - const communityListResponse = page.waitForResponse(hasGraphqlOperation(communityListOperationName), { timeout: 15_000 }).catch(() => null); - const memberListResponse = page.waitForResponse(hasGraphqlOperation(memberListOperationName), { timeout: 15_000 }).catch(() => null); - - await communityPage.clickCreate(); - - await communityPage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - const validationError = await communityPage.firstValidationError.isVisible().catch(() => false); - if (validationError) { - const errorText = await communityPage.firstValidationError.textContent(); - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Validation error')); - return; - } - - const mutationResponse = await createMutationResponse; - if (!mutationResponse) { - await communityPage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); - const hasErrorToast = await communityPage.errorToast.isVisible().catch(() => false); - const errorText = hasErrorToast ? await communityPage.errorToast.textContent() : null; - const message = errorText || `No ${createCommunityOperationName} GraphQL response was received`; - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as CommunityCreateGraphqlPayload | CommunityCreateGraphqlPayload[] | null, (data) => Boolean(data?.communityCreate)); - const graphqlError = graphqlErrors(payload); - const mutationResult = payload?.data?.communityCreate; - const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - const createdName = mutationResult?.community?.name ?? null; - - if (!mutationResponse.ok || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== name)) { - const message = - mutationError || - (mutationResult?.status?.success !== true - ? `${createCommunityOperationName} did not report success: ${JSON.stringify(payload)}` - : createdName !== name - ? `Expected created community name "${name}" but GraphQL returned "${createdName ?? 'null'}"` - : `Community create GraphQL request failed with HTTP ${mutationResponse.status()}`); - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const listResponse = await communityListResponse; - const listPayload = listResponse - ? selectGraphqlPayload((await listResponse.json().catch(() => null)) as CommunityListGraphqlPayload | CommunityListGraphqlPayload[] | null, (data) => data?.communitiesForCurrentEndUser !== undefined) - : null; - const listGraphqlError = graphqlErrors(listPayload); - const listContainsCreatedCommunity = listPayload?.data?.communitiesForCurrentEndUser?.some((community) => community.name === name) ?? false; - if (!listResponse?.ok() || listGraphqlError || !listContainsCreatedCommunity) { - const message = listGraphqlError || `Expected "${name}" in ${communityListOperationName} response after creation`; - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const membersResponse = await memberListResponse; - const membersPayload = membersResponse - ? selectGraphqlPayload((await membersResponse.json().catch(() => null)) as CommunityListGraphqlPayload | CommunityListGraphqlPayload[] | null, (data) => data?.membersForCurrentEndUser !== undefined) - : null; - const membersGraphqlError = graphqlErrors(membersPayload); - if (!membersResponse?.ok() || membersGraphqlError) { - const message = membersGraphqlError || `${memberListOperationName} did not complete successfully after creation`; - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await page.getByRole('cell', { name, exact: true }).first().waitFor({ state: 'visible', timeout: 5_000 }); - await actor.attemptsTo(notes().set('communityName', name), notes().set('communityCreated', true), notes().set('errorMessage', null)); - }); + Task.where( + the`#actor creates community "${name}" via UI`, + new TaskStep(`#actor submits community "${name}" through the browser UI`, async (actor) => { + const serenityActor = actor as Actor; + const { page } = BrowseTheWeb.withActor(serenityActor); + await page.goto('/community/accounts/create-community', { + waitUntil: 'networkidle', + }); + + const adapter = new PlaywrightPageAdapter(page); + const communityPage: E2ECommunityPage = new CommunityPage(adapter); + + await communityPage.fillName(name); + + const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createCommunityOperationName), { timeout: 15_000 }).catch(() => null); + const communityListResponse = page.waitForResponse(hasGraphqlOperation(communityListOperationName), { timeout: 15_000 }).catch(() => null); + const memberListResponse = page.waitForResponse(hasGraphqlOperation(memberListOperationName), { timeout: 15_000 }).catch(() => null); + + await communityPage.clickCreate(); + + await communityPage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + const validationError = await communityPage.firstValidationError.isVisible().catch(() => false); + if (validationError) { + const errorText = await communityPage.firstValidationError.textContent(); + await serenityActor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Validation error')); + return; + } + + const mutationResponse = await createMutationResponse; + if (!mutationResponse) { + await communityPage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + const hasErrorToast = await communityPage.errorToast.isVisible().catch(() => false); + const errorText = hasErrorToast ? await communityPage.errorToast.textContent() : null; + const message = errorText || `No ${createCommunityOperationName} GraphQL response was received`; + await serenityActor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as CommunityCreateGraphqlPayload | CommunityCreateGraphqlPayload[] | null, (data) => Boolean(data?.communityCreate)); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.communityCreate; + const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; + const createdName = mutationResult?.community?.name ?? null; + + if (!mutationResponse.ok || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== name)) { + const message = + mutationError || + (mutationResult?.status?.success !== true + ? `${createCommunityOperationName} did not report success: ${JSON.stringify(payload)}` + : createdName !== name + ? `Expected created community name "${name}" but GraphQL returned "${createdName ?? 'null'}"` + : `Community create GraphQL request failed with HTTP ${mutationResponse.status()}`); + await serenityActor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const listResponse = await communityListResponse; + const listPayload = listResponse + ? selectGraphqlPayload((await listResponse.json().catch(() => null)) as CommunityListGraphqlPayload | CommunityListGraphqlPayload[] | null, (data) => data?.communitiesForCurrentEndUser !== undefined) + : null; + const listGraphqlError = graphqlErrors(listPayload); + const listContainsCreatedCommunity = listPayload?.data?.communitiesForCurrentEndUser?.some((community) => community.name === name) ?? false; + if (!listResponse?.ok() || listGraphqlError || !listContainsCreatedCommunity) { + const message = listGraphqlError || `Expected "${name}" in ${communityListOperationName} response after creation`; + await serenityActor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const membersResponse = await memberListResponse; + const membersPayload = membersResponse + ? selectGraphqlPayload((await membersResponse.json().catch(() => null)) as CommunityListGraphqlPayload | CommunityListGraphqlPayload[] | null, (data) => data?.membersForCurrentEndUser !== undefined) + : null; + const membersGraphqlError = graphqlErrors(membersPayload); + if (!membersResponse?.ok() || membersGraphqlError) { + const message = membersGraphqlError || `${memberListOperationName} did not complete successfully after creation`; + await serenityActor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await page.getByRole('cell', { name, exact: true }).first().waitFor({ state: 'visible', timeout: 5_000 }); + await serenityActor.attemptsTo(notes().set('communityName', name), notes().set('communityCreated', true), notes().set('errorMessage', null)); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts index b7de3ea9a..92911f16d 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -1,4 +1,6 @@ -import { type Actor, Interaction, the } from '@serenity-js/core'; +import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { type Activity, type Actor, Task, the } from '@serenity-js/core'; import type { Page } from 'playwright'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; @@ -15,8 +17,8 @@ const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url. * The app uses RequireAuth + react-oidc-context. When an unauthenticated * user hits a protected route, RequireAuth calls `signinRedirect()` which * navigates to the mock OAuth2 server's `/authorize` endpoint. The mock - * server auto-completes the flow (no login form) and redirects back with a - * code that the OIDC library exchanges for tokens. + * server redirects to `/login` (since userStore is configured). This + * function fills in the test user credentials and submits the form. */ export async function performOAuth2Login(page: Page): Promise { // Navigate to a protected route to trigger the OIDC signinRedirect flow. @@ -29,11 +31,13 @@ export async function performOAuth2Login(page: Page): Promise { // Navigation may be interrupted by OIDC redirect — this is expected } - // If the mock OAuth2 server has a userStore, the /authorize endpoint - // redirects to a /login form instead of auto-completing the flow. - // Detect the login page and fill in credentials to proceed. + // Wait for redirects to settle on either the login page or the app + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => undefined); + + // If the mock OAuth2 login form is shown, fill credentials and submit. + // CommunityOwner is defined in mock-oidc.users.json with password "password". if (page.url().includes('/login')) { - await page.fill('input[name="username"]', 'test@example.com'); + await page.fill('input[name="username"]', actors.CommunityOwner.email); await page.fill('input[name="password"]', 'password'); await page.click('button[type="submit"]'); } @@ -44,32 +48,34 @@ export async function performOAuth2Login(page: Page): Promise { } /** - * Screenplay Interaction — confirms the actor is authenticated. + * Screenplay Task — confirms the actor is authenticated. * * The browser context is pre-authenticated by {@link performOAuth2Login} - * during server setup. This interaction navigates to a protected route and + * during server setup. This task navigates to a protected route and * verifies the page loads without being kicked to the auth provider. */ export const OAuth2Login = (_email?: string, _password?: string) => - Interaction.where(the`#actor logs in via OAuth2`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); + Task.where( + the`#actor logs in via OAuth2`, + new TaskStep('#actor confirms the OAuth2 session is active', async (actor) => { + const { page } = BrowseTheWeb.withActor(actor as Actor); - // Session tokens live in sessionStorage from pre-auth. - try { - await page.goto('/community/accounts', { - waitUntil: 'networkidle', - timeout: 30_000, - }); - } catch { - // Navigation may be interrupted by OIDC redirect on first access - } + // Session tokens live in sessionStorage from pre-auth. + try { + await page.goto('/community/accounts', { + waitUntil: 'networkidle', + timeout: 30_000, + }); + } catch { + // Navigation may be interrupted by OIDC redirect on first access + } - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', 'test@example.com'); - await page.fill('input[name="password"]', 'password'); - await page.click('button[type="submit"]'); - } + if (page.url().includes('/login')) { + await page.fill('input[name="username"]', actors.CommunityOwner.email); + await page.fill('input[name="password"]', 'password'); + await page.click('button[type="submit"]'); + } - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); - }); + await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts new file mode 100644 index 000000000..d8ea7b022 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts @@ -0,0 +1,12 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(currentDir, '../../../../../../..'); + +export const appPaths = { + apiDir: resolve(workspaceRoot, 'apps/api'), + oauth2MockDir: resolve(workspaceRoot, 'apps/server-oauth2-mock'), + uiCommunityDir: resolve(workspaceRoot, 'apps/ui-community'), + uiStaffDir: resolve(workspaceRoot, 'apps/ui-staff'), +} as const; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts new file mode 100644 index 000000000..1ce570cd1 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts @@ -0,0 +1,4 @@ +export function spawnEnv(overrides: Record = {}): NodeJS.ProcessEnv { + const { NODE_OPTIONS: _ignored, ...baseEnv } = process.env; + return { ...baseEnv, ...overrides }; +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts new file mode 100644 index 000000000..35476ef32 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts @@ -0,0 +1,10 @@ +export function getPortlessDevScript(): 'dev' | 'dev:worktree' { + return process.env['WORKTREE_NAME'] ? 'dev:worktree' : 'dev'; +} + +export function e2eEnv(overrides: Record = {}): Record { + return { + E2E: 'true', + ...overrides, + }; +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts index 880273cce..3f7b41971 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts @@ -1,7 +1,17 @@ export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; export { PortlessServer } from './portless-server.ts'; export { TestApiServer } from './test-api-server.ts'; +export { TestAzuriteServer } from './test-azurite-server.ts'; export { TestCommunityViteServer } from './test-community-vite-server.ts'; -export { buildUrl, cleanupTestEnvironment, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer, setMongoConnectionString } from './test-environment.ts'; +export { + buildUrl, + cleanupTestEnvironment, + initTestEnvironment, + mockOidcAudience, + mockOidcEndpoint, + mockOidcIssuer, + mockStaffOidcIssuer, + setMongoConnectionString, +} from './test-environment.ts'; export { TestOAuth2Server } from './test-oauth2-server.ts'; export { TestStaffViteServer } from './test-staff-vite-server.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts index 92d0e2308..feb84d100 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts @@ -1,21 +1,12 @@ import { type ChildProcess, spawn } from 'node:child_process'; import type { TestServer } from '@ocom-verification/verification-shared/servers'; import { getTimeout } from '@ocom-verification/verification-shared/settings'; +import { spawnEnv } from './child-process-env.ts'; +import { getPortlessPath } from './resolve-portless.ts'; /** - * Abstract base class for subprocess-backed test servers. - * Subclasses invoke an app package's own local script directly. - * - * This implements the TestServer interface for consistency with - * GraphQLTestServer (in-process), while providing subprocess isolation - * for full system tests. - * - * Use this for: - * - E2E tests requiring real running servers - * - Full system integration tests - * - Testing the actual build artifacts - * - * For faster API tests, use GraphQLTestServer instead. + * Abstract base class for portless-proxied servers. + * Subclasses define the hostname, command, ready marker, probe URL, and working directory. */ export abstract class PortlessServer implements TestServer { private process: ChildProcess | null = null; @@ -25,11 +16,11 @@ export abstract class PortlessServer implements TestServer { protected abstract get probeUrl(): string; protected abstract get readyMarker(): string; protected abstract get serverName(): string; - protected abstract get cwd(): string; protected abstract get spawnArgs(): string[]; + protected abstract get cwd(): string; protected get executable(): string { - return 'pnpm'; + return getPortlessPath(); } protected get probeRequestInit(): RequestInit { @@ -40,50 +31,27 @@ export abstract class PortlessServer implements TestServer { return {}; } + protected get startupTimeoutMs(): number { + return getTimeout('serverStartup'); + } + protected isProbeHealthy(response: Response): boolean | Promise { return response.ok; } - /** - * Check if server is already running (via health probe). - * Uses centralized health probe timeout. - */ isAlreadyRunning(): Promise { return this.isProbeReadyWithin(getTimeout('healthProbe')); } - private async isProbeReadyWithin(timeoutMs: number): Promise { - let timeout: ReturnType | undefined; - try { - const controller = new AbortController(); - timeout = setTimeout(() => controller.abort(), timeoutMs); - const res = await fetch(this.probeUrl, { ...this.probeRequestInit, signal: controller.signal }); - return await this.isProbeHealthy(res); - } catch { - return false; - } finally { - if (timeout) clearTimeout(timeout); - } - } + abstract getUrl(): string; - /** - * Start the server subprocess and wait for it to be ready. - * Uses centralized server startup timeout. - */ async start(): Promise { if (this.process || this.startedByUs) return; if (await this.isAlreadyRunning()) return; - const env = { - ...process.env, - ...this.extraEnv, - }; - // Remove NODE_OPTIONS from child process to avoid tsx import issues - delete env['NODE_OPTIONS']; - this.process = spawn(this.executable, this.spawnArgs, { cwd: this.cwd, - env, + env: spawnEnv(this.extraEnv), detached: this.useDetachedProcessGroup, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -92,10 +60,6 @@ export abstract class PortlessServer implements TestServer { await this.waitForReady(); } - /** - * Stop the server gracefully, with fallback to SIGKILL. - * Uses centralized server shutdown timeout. - */ async stop(): Promise { if (!this.process || !this.startedByUs) return; @@ -103,11 +67,6 @@ export abstract class PortlessServer implements TestServer { this.process = null; this.startedByUs = false; - // SIGINT (the same signal Ctrl+C sends in `pnpm dev`) lets portless's - // CLI run its cleanup branch — deregister the hostname from - // ~/.portless/routes.json before exiting. SIGTERM skips that handler in - // some tools and leaves stale state. Fall back to SIGKILL after the - // shutdown timeout for anything that ignores SIGINT. this.killProcess(proc, 'SIGINT'); const shutdownTimeout = getTimeout('serverShutdown'); @@ -124,28 +83,10 @@ export abstract class PortlessServer implements TestServer { }); } - /** - * Check if server is currently running (started by this instance). - */ isRunning(): boolean { return this.process !== null; } - /** - * Get the server URL. - * Subclasses must implement this to return the appropriate URL. - * @throws Error if server is not running - */ - abstract getUrl(): string; - - /** - * Get the startup timeout from centralized configuration. - * Subclasses can override for specific requirements. - */ - protected get startupTimeoutMs(): number { - return getTimeout('serverStartup'); - } - private waitForReady(): Promise { return new Promise((resolve, reject) => { const proc = this.process; @@ -180,8 +121,6 @@ export abstract class PortlessServer implements TestServer { }); }; - // stdout/stderr listeners detect the readyMarker and collect stderr - // for error reporting if the process exits unexpectedly. proc.stdout?.on('data', (data: Buffer) => { const text = data.toString(); if (text.includes(this.readyMarker)) { @@ -193,7 +132,7 @@ export abstract class PortlessServer implements TestServer { stderrOutput += data.toString(); }); - proc.on('error', (err) => { + proc.on('error', (err: Error) => { clearTimeout(timeout); this.process = null; this.startedByUs = false; @@ -201,6 +140,7 @@ export abstract class PortlessServer implements TestServer { }); proc.on('exit', (code, signal) => { + if (ready) return; clearTimeout(timeout); this.process = null; this.startedByUs = false; @@ -232,6 +172,20 @@ export abstract class PortlessServer implements TestServer { } } + private async isProbeReadyWithin(timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(this.probeUrl, { ...this.probeRequestInit, signal: controller.signal }); + return await this.isProbeHealthy(response); + } catch { + return false; + } finally { + if (timeout) clearTimeout(timeout); + } + } + private killProcess(proc: ChildProcess, signal: NodeJS.Signals): void { if (this.useDetachedProcessGroup && proc.pid) { try { diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts index 8c5cb9333..42c7f7827 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts @@ -1,14 +1,14 @@ import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const currentDir = fileURLToPath(new URL('.', import.meta.url)); +const currentDir = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(currentDir, '../../../../../../..'); let resolvedPath: string | undefined; export function getPortlessPath(): string { if (!resolvedPath) { - const workspaceRoot = resolve(currentDir, '../../../../../../..'); const localBin = resolve(workspaceRoot, 'node_modules/.bin/portless'); if (existsSync(localBin)) { resolvedPath = localBin; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts index f3fcdaeac..ea701b158 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts @@ -1,29 +1,18 @@ -import { execFileSync } from 'node:child_process'; -import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { appPaths } from './app-paths.ts'; +import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getMongoConnectionString, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; +import { buildUrl, getHostnames, getMongoConnectionString } from './test-environment.ts'; -export class TestApiServer extends PortlessServer { - override async start(): Promise { - // Mirror the app's real dev bootstrap so deploy assets and local settings - // stay in sync with recent package-script changes. - const env = { - ...process.env, - }; - delete env.NODE_OPTIONS; - - execFileSync('pnpm', ['run', 'predev'], { - cwd: this.cwd, - env, - stdio: 'pipe', - }); - - await super.start(); - } +const hostnames = getHostnames(); +/** + * Spawns the api e2e dev server through the PR's portless/worktree path. + */ +export class TestApiServer extends PortlessServer { protected get probeUrl() { - return buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + return this.getUrl(); } + protected override get probeRequestInit(): RequestInit { return { method: 'POST', @@ -31,6 +20,7 @@ export class TestApiServer extends PortlessServer { body: JSON.stringify({ query: '{ __typename }' }), }; } + protected override async isProbeHealthy(response: Response): Promise { if (!response.ok) { return false; @@ -43,49 +33,34 @@ export class TestApiServer extends PortlessServer { return payload?.data?.__typename === 'Query' && !payload.errors?.length; } + protected get readyMarker() { return 'Functions:'; } + protected get serverName() { return 'TestApiServer'; } + protected override get executable() { + return 'pnpm'; + } + protected get spawnArgs() { - return ['run', 'dev']; + return ['run', getPortlessDevScript()]; } + protected get cwd() { - return apiSettings.apiDir; + return appPaths.apiDir; } protected override get extraEnv() { - return { - // Force dev mode so OtelBuilder uses console exporters and doesn't - // require APPLICATIONINSIGHTS_CONNECTION_STRING. CI agents may - // inherit NODE_ENV=production from pipeline variable groups, which - // causes the bundled entry point to throw at module load and func - // to register zero functions ("No job functions found"), surfacing - // as a 404 on /api/graphql even though the host is alive. - NODE_ENV: 'development', - languageWorkers__node__arguments: '', + return e2eEnv({ COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), - COSMOSDB_DBNAME: apiSettings.cosmosDbName, - // AZURE_STORAGE_CONNECTION_STRING is required by ServiceBlobStorage - // at appStart. Locally set via gitignored local.settings.json; absent - // in CI without this override. - AZURE_STORAGE_CONNECTION_STRING: 'UseDevelopmentStorage=true', - ACCOUNT_PORTAL_OIDC_ISSUER: mockOidcIssuer, - ACCOUNT_PORTAL_OIDC_ENDPOINT: mockOidcEndpoint, - ACCOUNT_PORTAL_OIDC_AUDIENCE: mockOidcAudience, - ACCOUNT_PORTAL_OIDC_IGNORE_ISSUER: 'true', - STAFF_PORTAL_OIDC_ISSUER: mockOidcIssuer, - STAFF_PORTAL_OIDC_ENDPOINT: mockOidcEndpoint, - STAFF_PORTAL_OIDC_AUDIENCE: mockOidcAudience, - STAFF_PORTAL_OIDC_IGNORE_ISSUER: 'true', - VITE_COMMON_API_ENDPOINT: buildUrl('data-access.ownercommunity.localhost', '/api/graphql'), - }; + }); } getUrl(): string { - return buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + return buildUrl(hostnames.api, '/api/graphql'); } } diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts new file mode 100644 index 000000000..9f3c8f28b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts @@ -0,0 +1,124 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { join } from 'node:path'; +import type { TestServer } from '@ocom-verification/verification-shared/servers'; +import { getTimeout } from '@ocom-verification/verification-shared/settings'; +import { appPaths } from './app-paths.ts'; +import { spawnEnv } from './child-process-env.ts'; +import { getAzuritePorts } from './worktree-ports.ts'; + +/** + * Starts Azurite via apps/api/start-azurite.mjs. + * If ports are already bound (EADDRINUSE), we treat that as an existing + * reusable instance for this worktree. + */ +export class TestAzuriteServer implements TestServer { + private process: ChildProcess | null = null; + private startedByUs = false; + private readonly useDetachedProcessGroup = process.platform !== 'win32'; + + private get blobPort(): number { + return getAzuritePorts().blob; + } + + async start(): Promise { + if (this.process || this.startedByUs) return; + + const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); + const { PATH: pathValue = '' } = process.env; + + this.process = spawn('node', ['start-azurite.mjs'], { + cwd: appPaths.apiDir, + env: spawnEnv({ PATH: `${binDir}:${pathValue}` }), + detached: this.useDetachedProcessGroup, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.startedByUs = true; + + await this.waitForStartedMarker(); + } + + async stop(): Promise { + if (!this.process || !this.startedByUs) return; + + const proc = this.process; + this.process = null; + this.startedByUs = false; + + killProcess(proc, 'SIGTERM', this.useDetachedProcessGroup); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + killProcess(proc, 'SIGKILL', this.useDetachedProcessGroup); + resolve(); + }, getTimeout('serverShutdown')); + + proc.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + isRunning(): boolean { + return this.process !== null; + } + + getUrl(): string { + return `http://127.0.0.1:${this.blobPort}`; + } + + private waitForStartedMarker(): Promise { + return new Promise((resolve, reject) => { + const proc = this.process; + if (!proc) { + reject(new Error('TestAzuriteServer process not started')); + return; + } + + const timeout = setTimeout(() => { + reject(new Error(`TestAzuriteServer did not emit start marker within ${getTimeout('serverStartup')}ms`)); + }, getTimeout('serverStartup')); + + let stderrOutput = ''; + + proc.stdout?.on('data', (data: Buffer) => { + if (data.toString().includes('[azurite] started')) { + clearTimeout(timeout); + resolve(); + } + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderrOutput += data.toString(); + }); + + proc.on('error', (error: Error) => { + clearTimeout(timeout); + reject(new Error(`TestAzuriteServer failed to start: ${error.message}`)); + }); + + proc.on('exit', (code, signal) => { + clearTimeout(timeout); + if (stderrOutput.includes('EADDRINUSE')) { + this.process = null; + this.startedByUs = false; + resolve(); + return; + } + reject(new Error(`TestAzuriteServer exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); + }); + }); + } +} + +function killProcess(proc: ChildProcess, signal: NodeJS.Signals, useGroup: boolean): void { + if (useGroup && proc.pid) { + try { + process.kill(-proc.pid, signal); + return; + } catch { + /* Fall back to killing the direct child. */ + } + } + proc.kill(signal); +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts index 096d345da..fb567e0aa 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts @@ -1,45 +1,46 @@ -import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { appPaths } from './app-paths.ts'; +import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; import { PortlessServer } from './portless-server.ts'; -import { buildUrl, mockOidcIssuer } from './test-environment.ts'; +import { buildUrl, getHostnames } from './test-environment.ts'; + +const hostnames = getHostnames(); /** - * Starts the community (user) portal Vite dev server as a subprocess via `pnpm run dev`. - * This is for the owner-community UI only; a separate server class will be needed for the staff portal. + * Starts the community portal Vite dev server via portless. */ export class TestCommunityViteServer extends PortlessServer { protected get probeUrl() { - return buildUrl('ownercommunity.localhost'); + return this.getUrl(); } + protected get readyMarker() { return 'ready in'; } + protected get serverName() { return 'TestCommunityViteServer'; } + protected override get executable() { + return 'pnpm'; + } + protected get spawnArgs() { - return ['run', 'dev']; + return ['run', getPortlessDevScript()]; } + protected get cwd() { - return apiSettings.uiCommunityDir; + return appPaths.uiCommunityDir; } protected override get extraEnv() { - const uiBase = buildUrl('ownercommunity.localhost'); - const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); - - return { + return e2eEnv({ BROWSER: 'none', NODE_ENV: 'development', - VITE_BASE_URL: uiBase, - VITE_APP_UI_COMMUNITY_B2C_AUTHORITY: mockOidcIssuer, - VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: `${uiBase}/auth-redirect`, - VITE_COMMON_API_ENDPOINT: apiEndpoint, - VITE_FUNCTION_ENDPOINT: apiEndpoint, - }; + }); } getUrl(): string { - return buildUrl('ownercommunity.localhost'); + return buildUrl(hostnames.uiCommunity); } } diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts index 771c04c53..f93859c16 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts @@ -1,46 +1,50 @@ import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildPortlessUrl, getHostnames } from '@ocom-verification/verification-shared/settings'; import { getPortlessPath } from './resolve-portless.ts'; let proxyInitialized = false; let mongoConnectionString: string | undefined; +loadE2EEnvDefaults(); + +const hostnames = getHostnames(); + +export const mockOidcAudience = 'mock-client'; +export const mockOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/community'); +export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; +export const mockStaffOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/staff'); + +/** + * Ensure the portless proxy is running for the PR's worktree-scoped hostnames. + */ export function initTestEnvironment() { if (proxyInitialized) return; - // Clean up orphaned route locks from previous runs that crashed or were killed. - // The proxy itself is started by the test:e2e script so the portless CA exists - // before Node reads NODE_EXTRA_CA_CERTS at startup. execFileSync(getPortlessPath(), ['prune'], { timeout: 10_000, stdio: 'pipe', }); + execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { + timeout: 15_000, + stdio: 'pipe', + }); proxyInitialized = true; } -export function buildUrl(hostname: string, path = ''): string { - return `https://${hostname}:1355${path}`; -} - -/** - * Mock OIDC URLs derived from the portless hostname and the portal name - * registered by server-oauth2-mock (via apps/ui-community/mock-oidc.json). - * - * These are hardcoded here so the e2e test infrastructure is self-contained - * and does not depend on potentially-stale local.settings.json values. - */ -export const mockOidcIssuer = buildUrl('mock-auth.ownercommunity.localhost', '/community'); -export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; -export const mockOidcAudience = 'mock-client'; - -export const mockStaffOidcIssuer = buildUrl('mock-auth.ownercommunity.localhost', '/staff'); +export { buildPortlessUrl as buildUrl, getHostnames }; export function setMongoConnectionString(connStr: string): void { mongoConnectionString = connStr; } export function getMongoConnectionString(): string { - if (!mongoConnectionString) throw new Error('MongoDB connection string not set. Start MongoDBTestServer first.'); + if (!mongoConnectionString) { + throw new Error('MongoDB connection string not set - call setMongoConnectionString() first'); + } return mongoConnectionString; } @@ -48,3 +52,19 @@ export function cleanupTestEnvironment(): void { proxyInitialized = false; mongoConnectionString = undefined; } + +function loadE2EEnvDefaults(): void { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const workspaceRoot = resolve(currentDir, '../../../../../../..'); + for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { + if (!existsSync(filePath)) continue; + for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + const key = trimmed.slice(0, idx); + process.env[key] ??= trimmed.slice(idx + 1); + } + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts index 2ab755f70..424d028e3 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts @@ -1,23 +1,34 @@ -import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { appPaths } from './app-paths.ts'; +import { getPortlessDevScript } from './dev-script.ts'; import { PortlessServer } from './portless-server.ts'; import { mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; +/** + * Starts the mock OAuth2/OIDC server via portless. + */ export class TestOAuth2Server extends PortlessServer { protected get probeUrl() { return mockOidcEndpoint; } + protected get readyMarker() { return 'Registered OIDC config'; } + protected get serverName() { return 'TestOAuth2Server'; } + protected override get executable() { + return 'pnpm'; + } + protected get spawnArgs() { - return ['run', 'dev']; + return ['run', getPortlessDevScript()]; } + protected get cwd() { - return apiSettings.oauth2MockDir; + return appPaths.oauth2MockDir; } getUrl(): string { diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts index 897a3d3db..8eab2f068 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -1,45 +1,46 @@ -import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { appPaths } from './app-paths.ts'; +import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; import { PortlessServer } from './portless-server.ts'; -import { buildUrl, mockStaffOidcIssuer } from './test-environment.ts'; +import { buildUrl, getHostnames } from './test-environment.ts'; + +const hostnames = getHostnames(); /** - * Starts the staff portal Vite dev server as a subprocess via `pnpm run dev`. + * Starts the staff portal Vite dev server via portless. */ export class TestStaffViteServer extends PortlessServer { protected get probeUrl() { - return buildUrl('staff.ownercommunity.localhost'); + return this.getUrl(); } + protected get readyMarker() { return 'ready in'; } + protected get serverName() { return 'TestStaffViteServer'; } + protected override get executable() { + return 'pnpm'; + } + protected get spawnArgs() { - return ['run', 'dev']; + return ['run', getPortlessDevScript()]; } + protected get cwd() { - return apiSettings.uiStaffDir; + return appPaths.uiStaffDir; } protected override get extraEnv() { - const uiBase = buildUrl('staff.ownercommunity.localhost'); - const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); - - return { + return e2eEnv({ BROWSER: 'none', NODE_ENV: 'development', - VITE_BASE_URL: uiBase, - VITE_APP_UI_STAFF_AAD_AUTHORITY: mockStaffOidcIssuer, - VITE_APP_UI_STAFF_AAD_REDIRECT_URI: `${uiBase}/auth-redirect`, - VITE_APP_UI_STAFF_AAD_CLIENTID: 'mock-client', - VITE_COMMON_API_ENDPOINT: apiEndpoint, - VITE_FUNCTION_ENDPOINT: apiEndpoint, - }; + }); } getUrl(): string { - return buildUrl('staff.ownercommunity.localhost'); + return buildUrl(hostnames.uiStaff); } } diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts new file mode 100644 index 000000000..022abad6b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts @@ -0,0 +1,26 @@ +interface AzuritePorts { + blob: number; + queue: number; + table: number; +} + +export function getWorktreePortOffset(): number { + const name = process.env['WORKTREE_NAME']; + if (!name) return 0; + let hash = 0; + for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; + return ((Math.abs(hash) % 49) + 1) * 100; +} + +export function getAzuritePorts(): AzuritePorts { + const offset = getWorktreePortOffset(); + return { + blob: 10000 + offset, + queue: 10001 + offset, + table: 10002 + offset, + }; +} + +export function getMongoPort(): number { + return 50000 + getWorktreePortOffset(); +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index cb1a4e12e..472301f78 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -1,9 +1,13 @@ import playwright, { type Browser, type BrowserContext } from 'playwright'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; import { performOAuth2Login } from './oauth2-login.ts'; -import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestCommunityViteServer, TestOAuth2Server, TestStaffViteServer } from './servers/index.ts'; +import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestAzuriteServer, TestCommunityViteServer, TestOAuth2Server, TestStaffViteServer } from './servers/index.ts'; +import { getMongoPort } from './servers/worktree-ports.ts'; + +const apiDbName = 'owner-community'; let mongoDBServer: MongoDBTestServer | undefined; +let azuriteServer: TestAzuriteServer | undefined; let oauth2Server: TestOAuth2Server | undefined; let apiServer: TestApiServer | undefined; let communityViteServer: TestCommunityViteServer | undefined; @@ -29,8 +33,6 @@ export function getState(): InfrastructureState { /** * Resets mutable state between scenarios without restarting servers. - * Drops all MongoDB collections and re-seeds reference data so each - * scenario starts from a clean baseline. */ export async function resetScenarioState(): Promise { if (mongoDBServer?.isRunning()) { @@ -46,6 +48,7 @@ export async function stopAll(): Promise { await authenticatedBrowserContext.close().catch(() => undefined); } authenticatedBrowserContext = undefined; + if (browser) { await browser.close().catch(() => undefined); browser = undefined; @@ -70,6 +73,11 @@ export async function stopAll(): Promise { await mongoDBServer.stop().catch(() => undefined); mongoDBServer = undefined; } + if (azuriteServer) { + await azuriteServer.stop().catch(() => undefined); + azuriteServer = undefined; + } + apiUrl = undefined; browserBaseUrl = undefined; cleanupTestEnvironment(); @@ -77,31 +85,41 @@ export async function stopAll(): Promise { export async function ensureE2EServers(): Promise { initTestEnvironment(); - registerShutdownHandlers(); - // Phase 1: Start MongoDB and OAuth2 in parallel (no interdependency) mongoDBServer ??= new MongoDBTestServer(); + azuriteServer ??= new TestAzuriteServer(); oauth2Server ??= new TestOAuth2Server(); + const mongo = mongoDBServer; + const azurite = azuriteServer; const oauth2 = oauth2Server; const phase1: Promise[] = []; + if (!mongo.isRunning()) { - phase1.push(mongo.start().then(() => setMongoConnectionString(mongo.getConnectionString()))); + phase1.push( + mongo.start({ dbName: apiDbName, port: getMongoPort() }).then(() => { + setMongoConnectionString(mongo.getConnectionString()); + }), + ); + } + if (!azurite.isRunning()) { + phase1.push(azurite.start()); } if (!oauth2.isRunning()) { phase1.push(oauth2.start()); } if (phase1.length > 0) await Promise.all(phase1); - // Phase 2: Start API (needs MongoDB conn string), Vite (independent), and generate token (needs OAuth2) in parallel apiServer ??= new TestApiServer(); communityViteServer ??= new TestCommunityViteServer(); staffViteServer ??= new TestStaffViteServer(); + const api = apiServer; - const vite = communityViteServer; + const communityVite = communityViteServer; const staffVite = staffViteServer; const phase2: Promise[] = []; + if (!api.isRunning()) { phase2.push( api.start().then(() => { @@ -109,19 +127,16 @@ export async function ensureE2EServers(): Promise { }), ); } - if (!vite.isRunning()) { - phase2.push(vite.start()); + if (!communityVite.isRunning()) { + phase2.push(communityVite.start()); } if (!staffVite.isRunning()) { phase2.push(staffVite.start()); } if (phase2.length > 0) await Promise.all(phase2); - browserBaseUrl = communityViteServer.getUrl(); - - if (!apiUrl) { - apiUrl = apiServer?.getUrl(); - } + browserBaseUrl = communityVite.getUrl(); + apiUrl ??= api.getUrl(); if (!browser) { browser = await playwright.chromium.launch({ headless: true }); diff --git a/packages/ocom-verification/e2e-tests/turbo.json b/packages/ocom-verification/e2e-tests/turbo.json index 87d650253..5a7a5f821 100644 --- a/packages/ocom-verification/e2e-tests/turbo.json +++ b/packages/ocom-verification/e2e-tests/turbo.json @@ -3,7 +3,17 @@ "tasks": { "test:e2e": { "dependsOn": ["^build"], - "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], + "inputs": [ + "src/**/*.ts", + "config/**", + "$TURBO_ROOT$/apps/api/local-settings.e2e.json", + "$TURBO_ROOT$/apps/api/scripts/sync-local-settings.mjs", + "$TURBO_ROOT$/apps/ui-community/.env.e2e", + "$TURBO_ROOT$/apps/ui-staff/.env.e2e", + "$TURBO_ROOT$/scripts/local-dev/**", + "cucumber.js", + "package.json" + ], "cache": false }, "test:e2e:ci": { @@ -13,7 +23,17 @@ }, "test:serenity": { "dependsOn": ["^build"], - "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], + "inputs": [ + "src/**/*.ts", + "config/**", + "$TURBO_ROOT$/apps/api/local-settings.e2e.json", + "$TURBO_ROOT$/apps/api/scripts/sync-local-settings.mjs", + "$TURBO_ROOT$/apps/ui-community/.env.e2e", + "$TURBO_ROOT$/apps/ui-staff/.env.e2e", + "$TURBO_ROOT$/scripts/local-dev/**", + "cucumber.js", + "package.json" + ], "cache": false } } diff --git a/packages/ocom-verification/verification-shared/package.json b/packages/ocom-verification/verification-shared/package.json index 13c3822aa..9d4803c01 100644 --- a/packages/ocom-verification/verification-shared/package.json +++ b/packages/ocom-verification/verification-shared/package.json @@ -10,6 +10,7 @@ "./formatters": "./src/formatters/index.ts", "./servers": "./src/servers/index.ts", "./settings": "./src/settings/index.ts", + "./serenity": "./src/serenity/index.ts", "./pages": "./src/pages/index.ts", "./pages/jsdom": "./src/pages/adapters/jsdom-adapter.ts", "./pages/playwright": "./src/pages/adapters/playwright-adapter.ts" @@ -20,6 +21,7 @@ "@ocom/service-mongoose": "workspace:*", "@cucumber/cucumber": "catalog:", "@cucumber/messages": "catalog:", + "@serenity-js/core": "catalog:", "@ocom/graphql": "workspace:*", "@ocom/application-services": "workspace:*", "@testing-library/react": "^16.3.0", diff --git a/packages/ocom-verification/verification-shared/src/serenity/index.ts b/packages/ocom-verification/verification-shared/src/serenity/index.ts new file mode 100644 index 000000000..54ba8c2e5 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/serenity/index.ts @@ -0,0 +1 @@ +export * from './task-step.ts'; diff --git a/packages/ocom-verification/verification-shared/src/serenity/task-step.ts b/packages/ocom-verification/verification-shared/src/serenity/task-step.ts new file mode 100644 index 000000000..b11ff0a82 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/serenity/task-step.ts @@ -0,0 +1,14 @@ +import { Task } from '@serenity-js/core'; + +export class TaskStep extends Task { + constructor( + description: string, + private readonly action: (actor: unknown) => Promise, + ) { + super(description); + } + + performAs(actor: unknown): Promise { + return this.action(actor); + } +} diff --git a/packages/ocom-verification/verification-shared/src/servers/index.ts b/packages/ocom-verification/verification-shared/src/servers/index.ts index 32810914a..4f3a5ec63 100644 --- a/packages/ocom-verification/verification-shared/src/servers/index.ts +++ b/packages/ocom-verification/verification-shared/src/servers/index.ts @@ -1,10 +1,8 @@ +export { seedDatabase } from '../test-data/index.ts'; export { GraphQLTestServer } from './graphql-test-server.ts'; export type { MongoDBSeedDataFunction, MongoDBTestServerStartOptions, } from './test-mongodb-server.ts'; -export { - MongoDBTestServer, - seedOwnerCommunityReferenceData, -} from './test-mongodb-server.ts'; -export type { TestServer, TestServerOptions } from './test-server.interface.ts'; +export { MongoDBTestServer } from './test-mongodb-server.ts'; +export type { TestServer } from './test-server.interface.ts'; diff --git a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts b/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts index 53fa6fc1a..7ae4a488f 100644 --- a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts +++ b/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts @@ -1,106 +1,57 @@ -import { type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; +import { type MongoMemoryReplicaSetConfig, type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; import { ServiceMongoose } from '@ocom/service-mongoose'; -import { MongoClient, ObjectId } from 'mongodb'; -import { apiSettings } from '../settings/index.ts'; -import { getAllMockUsers } from '../test-data/index.ts'; +import { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from '@ocom-verification/verification-shared/test-data'; +import { MongoClient } from 'mongodb'; +const DEFAULT_DB_NAME = 'owner-community'; +const DEFAULT_MONGO_PORT = 50_000; const DEFAULT_REPL_SET_NAME = 'globaldb'; -export type MongoDBSeedDataFunction = (connectionString: string, dbName: string) => Promise; +export type { MongoDBSeedDataFunction }; export interface MongoDBTestServerStartOptions { dbName?: string; port?: number; - seedDataFn?: MongoDBSeedDataFunction; -} - -export async function seedOwnerCommunityReferenceData(connectionString: string, dbName: string): Promise { - const client = new MongoClient(connectionString); - try { - await client.connect(); - const db = client.db(dbName); - - const users = getAllMockUsers(); - if (users.length > 0) { - const operations = users.map((user) => ({ - updateOne: { - filter: { _id: new ObjectId(user.id) }, - update: { - $setOnInsert: { - _id: new ObjectId(user.id), - externalId: user.externalId, - displayName: user.displayName, - email: user.email, - personalInformation: user.personalInformation, - accessBlocked: user.accessBlocked, - tags: user.tags, - userType: user.userType, - schemaVersion: user.schemaVersion, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - }, - upsert: true, - }, - })); - await db.collection('users').bulkWrite(operations); - } - } finally { - await client.close(); - } + replSetName?: string; + binaryVersion?: string; + attachMongoose?: boolean; + seedData?: MongoDBSeedDataFunction; } /** - * Test wrapper around the Cellix MongoDB memory mock seedwork. - * The replica set is started by @cellix/server-mongodb-memory-mock-seedwork; this class - * owns readiness checks, test seeding, and the Mongoose service used by tests. + * In-memory MongoDB replica set for verification tests. */ export class MongoDBTestServer { private disposer: MongoMemoryReplicaSetDisposer | null = null; private serviceMongoose: ServiceMongoose | null = null; private connectionString = ''; - private dbName = apiSettings.cosmosDbName; - private startedByUs = false; + private dbName = DEFAULT_DB_NAME; + private seedData: MongoDBSeedDataFunction = seedDatabase; async start(options?: MongoDBTestServerStartOptions): Promise { - this.dbName = options?.dbName ?? apiSettings.cosmosDbName; - const port = options?.port ?? apiSettings.cosmosDbPort; - const replSetName = getReplicaSetName(apiSettings.cosmosDbConnectionString) ?? DEFAULT_REPL_SET_NAME; - this.connectionString = buildConnectionString({ port, dbName: this.dbName, replSetName }); - - if (!(await MongoDBTestServer.isReachable(this.connectionString))) { - const { disposer } = await startMongoMemoryReplicaSet({ - port, - dbName: this.dbName, - replSetName, - }); - this.disposer = disposer; - this.startedByUs = true; - } - - this.serviceMongoose = new ServiceMongoose(this.connectionString, { - dbName: this.dbName, - autoIndex: true, - autoCreate: true, - }); - await this.serviceMongoose.startUp(); - - const { connection } = this.serviceMongoose.service; - for (const modelName of Object.keys(connection.models)) { - try { - connection.deleteModel(modelName); - } catch { - /* already deleted */ - } + const config: MongoMemoryReplicaSetConfig = { + port: options?.port ?? DEFAULT_MONGO_PORT, + dbName: options?.dbName ?? DEFAULT_DB_NAME, + replSetName: options?.replSetName ?? DEFAULT_REPL_SET_NAME, + ...(options?.binaryVersion && { binaryVersion: options.binaryVersion }), + }; + + this.dbName = config.dbName; + this.seedData = options?.seedData ?? seedDatabase; + + const { connectionString, disposer } = await startMongoMemoryReplicaSet(config); + this.disposer = disposer; + this.connectionString = connectionString; + await this.seed(); + + if (options?.attachMongoose) { + await this.attachMongoose(); } - - const seedFn = options?.seedDataFn ?? seedOwnerCommunityReferenceData; - await seedFn(this.connectionString, this.dbName); } getServiceMongoose(): ServiceMongoose { if (!this.serviceMongoose) { - throw new Error('MongoDBTestServer not started'); + throw new Error('MongoDBTestServer Mongoose service not attached'); } return this.serviceMongoose; } @@ -112,65 +63,68 @@ export class MongoDBTestServer { return this.connectionString; } + async resetForScenario(seedData?: MongoDBSeedDataFunction): Promise { + if (!this.connectionString) { + throw new Error('MongoDBTestServer not started'); + } + + await clearDatabase({ connectionString: this.connectionString, dbName: this.dbName }); + await this.seed(seedData); + } + async stop(): Promise { if (this.serviceMongoose) { await this.serviceMongoose.shutDown(); this.serviceMongoose = null; } - if (this.disposer && this.startedByUs) { + if (this.disposer) { const disposer = this.disposer; this.disposer = null; - this.startedByUs = false; await disposer.stop(); } - } - - async resetForScenario(seedDataFn?: MongoDBSeedDataFunction): Promise { - if (!this.serviceMongoose) { - throw new Error('MongoDBTestServer not started'); - } - const { connection } = this.serviceMongoose.service; - const { db } = connection; - if (!db) { - throw new Error('Mongoose connection has no active db'); - } - const collections = await db.listCollections({}, { nameOnly: true }).toArray(); - await Promise.all(collections.map((c) => db.collection(c.name).deleteMany({}))); - const seedFn = seedDataFn ?? seedOwnerCommunityReferenceData; - await seedFn(this.connectionString, this.dbName); + this.connectionString = ''; } isRunning(): boolean { - return this.serviceMongoose !== null; + return this.disposer !== null; } - static async isReachable(connectionString: string): Promise { - const client = new MongoClient(connectionString, { - serverSelectionTimeoutMS: 3_000, - connectTimeoutMS: 3_000, + private async attachMongoose(): Promise { + this.serviceMongoose = new ServiceMongoose(this.connectionString, { + dbName: this.dbName, + autoIndex: true, + autoCreate: true, }); + await this.serviceMongoose.startUp(); + this.clearMongooseModels(); + } + + private clearMongooseModels(): void { + const connection = this.serviceMongoose?.service.connection; + if (!connection) return; - try { - await client.connect(); - await client.db().command({ ping: 1 }); - return true; - } catch { - return false; - } finally { - await client.close(); + for (const modelName of Object.keys(connection.models)) { + try { + connection.deleteModel(modelName); + } catch { + /* already deleted */ + } } } - static async seedData(connectionString: string, dbName: string): Promise { - await seedOwnerCommunityReferenceData(connectionString, dbName); + private async seed(seedData = this.seedData): Promise { + await seedData({ connectionString: this.connectionString, dbName: this.dbName }); } } -function buildConnectionString(config: { port: number; dbName: string; replSetName: string }): string { - return `mongodb://127.0.0.1:${config.port}/${config.dbName}?replicaSet=${config.replSetName}`; -} - -function getReplicaSetName(connectionString: string): string | undefined { - const match = /[?&]replicaSet=([^&]+)/.exec(connectionString); - return match?.[1] ? decodeURIComponent(match[1]) : undefined; +async function clearDatabase(context: MongoDBSeedContext): Promise { + const client = new MongoClient(context.connectionString); + try { + await client.connect(); + const db = client.db(context.dbName); + const collections = await db.listCollections({}, { nameOnly: true }).toArray(); + await Promise.all(collections.map((collection) => db.collection(collection.name).deleteMany({}))); + } finally { + await client.close(); + } } diff --git a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts b/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts index 8b08f6b92..16e024f23 100644 --- a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts +++ b/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts @@ -1,15 +1,7 @@ /** * Common interface for all test servers (in-process and subprocess). - * - * This abstraction allows acceptance-api and e2e tests to use - * consistent server lifecycle management patterns while choosing - * the appropriate implementation: - * - * - **In-process** (GraphQLTestServer): Fast, isolated, mocked services - * Best for: API acceptance tests, unit-like integration tests - * - * - **Subprocess** (PortlessServer): Full stack, realistic, real services - * Best for: E2E tests, full system integration tests + * Implemented by GraphQLTestServer (in-process), PortlessServer (subprocess + * via the portless proxy), and TestAzuriteServer. */ export interface TestServer { /** Start the server and return when ready */ @@ -24,17 +16,3 @@ export interface TestServer { /** Get the server URL (throws if not running) */ getUrl(): string; } - -/** - * Configuration options for test server startup. - */ -export interface TestServerOptions { - /** Port to listen on (0 for random available port) */ - port?: number; - - /** Additional environment variables for subprocess servers */ - env?: Record; - - /** Timeout for server startup (defaults to centralized config) */ - startupTimeoutMs?: number; -} diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts index 4e28ad534..5969fa475 100644 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ b/packages/ocom-verification/verification-shared/src/settings/index.ts @@ -1,10 +1,2 @@ -export { apiSettings, uiSettings } from './local-settings.ts'; -export { - findWorkspaceRoot, - readDotEnv, - readJsonSettings, - readSetting, - requireSetting, - resolveWorkspacePath, -} from './settings-utils.ts'; +export { buildPortlessUrl, getHostnames, PORTLESS_PORT } from './portless-settings.ts'; export { getTimeout, type TimeoutKey, timeouts } from './timeout-settings.ts'; diff --git a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts deleted file mode 100644 index d075b6b25..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ /dev/null @@ -1,52 +0,0 @@ -import path from 'node:path'; -import { findWorkspaceRoot, readDotEnv, readJsonSettings, readSetting, requireSetting, resolveWorkspacePath } from './settings-utils.ts'; - -const workspaceRoot = findWorkspaceRoot(); -const apiSettingsPath = resolveWorkspacePath(workspaceRoot, 'apps/api/local.settings.json'); -const uiEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); - -const apiValues = readJsonSettings(apiSettingsPath); -const uiValues = readDotEnv(uiEnvPath); - -/** - * Defaults for E2E/acceptance test settings when local.settings.json is absent - * (e.g. CI pipelines). All values are non-secret mock/localhost references used - * exclusively by the test harness — no real credentials are involved. - */ -const ciDefaults = { - COSMOSDB_CONNECTION_STRING: '', - COSMOSDB_DBNAME: 'owner-community', - COSMOSDB_PORT: '50000', - NODE_ENV: 'development', - ACCOUNT_PORTAL_OIDC_AUDIENCE: 'mock-client', - ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.localhost:1355/community', - ACCOUNT_PORTAL_OIDC_ENDPOINT: 'https://mock-auth.ownercommunity.localhost:1355/community/.well-known/jwks.json', -} as const; - -function setting(key: keyof typeof ciDefaults): string { - return readSetting(apiValues, key, ciDefaults[key]) ?? ciDefaults[key]; -} - -export const apiSettings = { - nodeEnv: setting('NODE_ENV'), - isDevelopment: setting('NODE_ENV') === 'development', - - cosmosDbConnectionString: setting('COSMOSDB_CONNECTION_STRING'), - cosmosDbName: setting('COSMOSDB_DBNAME'), - cosmosDbPort: Number(setting('COSMOSDB_PORT')), - - accountPortalOidcIssuer: setting('ACCOUNT_PORTAL_OIDC_ISSUER'), - accountPortalOidcEndpoint: setting('ACCOUNT_PORTAL_OIDC_ENDPOINT'), - accountPortalOidcAudience: setting('ACCOUNT_PORTAL_OIDC_AUDIENCE'), - - apiDir: path.dirname(apiSettingsPath), - oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), - uiCommunityDir: path.dirname(uiEnvPath), - uiStaffDir: path.join(workspaceRoot, 'apps', 'ui-staff'), -} as const; - -export const uiSettings = { - baseUrl: requireSetting(uiValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in .env'), - - graphqlEndpoint: requireSetting(uiValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in .env'), -} as const; diff --git a/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts b/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts new file mode 100644 index 000000000..9415b0a64 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts @@ -0,0 +1,58 @@ +interface PortlessHostnames { + uiCommunity: string; + uiStaff: string; + api: string; + mockAuth: string; + docs: string; +} + +const PORTLESS_PORT = 1355; + +function buildPortlessUrl(hostname: string, path = ''): string { + return `https://${hostname}:${PORTLESS_PORT}${path}`; +} + +function getHostnames(): PortlessHostnames { + const hostnames = { + uiCommunity: requireHostname('VITE_APP_UI_COMMUNITY_BASE_URL'), + uiStaff: requireHostname('VITE_APP_UI_STAFF_AAD_REDIRECT_URI'), + api: requireHostname('VITE_COMMON_API_ENDPOINT'), + mockAuth: requireHostname('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY'), + }; + const { WORKTREE_NAME: worktreeName = '' } = process.env; + + return applyWorktreeSuffixes(hostnames, worktreeName); +} + +function hostnameFrom(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +function requireHostname(key: string): string { + const hostname = hostnameFrom(process.env[key] ?? ''); + if (!hostname) { + throw new Error(`portless-settings: required env var ${key} is missing or invalid`); + } + return hostname; +} + +function applyWorktreeSuffixes(hostnames: Omit, worktreeName: string): PortlessHostnames { + return { + uiCommunity: applyWorktreeSuffix(hostnames.uiCommunity, worktreeName), + uiStaff: applyWorktreeSuffix(hostnames.uiStaff, worktreeName), + api: applyWorktreeSuffix(hostnames.api, worktreeName), + mockAuth: applyWorktreeSuffix(hostnames.mockAuth, worktreeName), + docs: applyWorktreeSuffix(`docs.${hostnames.uiCommunity}`, worktreeName), + }; +} + +function applyWorktreeSuffix(hostname: string, worktreeName: string): string { + if (!worktreeName) return hostname; + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +export { buildPortlessUrl, getHostnames, PORTLESS_PORT }; diff --git a/packages/ocom-verification/verification-shared/src/settings/settings-utils.ts b/packages/ocom-verification/verification-shared/src/settings/settings-utils.ts deleted file mode 100644 index c51a891eb..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/settings-utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const defaultStartDir = fileURLToPath(new URL('.', import.meta.url)); - -/** - * Walks up from `startDir` looking for pnpm-workspace.yaml to locate the - * monorepo root. Projects that use a different workspace marker should pass a - * custom `markerFile`. - */ -export function findWorkspaceRoot(startDir = defaultStartDir, markerFile = 'pnpm-workspace.yaml'): string { - let dir = startDir; - - while (dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, markerFile))) { - return dir; - } - dir = path.dirname(dir); - } - - throw new Error(`Could not find workspace root (${markerFile})`); -} - -/** - * Reads an Azure Functions-style settings file ({ "Values": { ... } }) and - * returns the Values map. Returns `{}` if the file doesn't exist. - */ -export function readJsonSettings(filePath: string): Record { - if (!fs.existsSync(filePath)) { - return {}; - } - const raw = fs.readFileSync(filePath, 'utf-8'); - const parsed = JSON.parse(raw) as { Values?: Record }; - return parsed.Values ?? {}; -} - -/** - * Reads a simple `KEY=VALUE` .env file. Ignores blank lines and `#` comments. - * Returns `{}` if the file doesn't exist. - */ -export function readDotEnv(filePath: string): Record { - if (!fs.existsSync(filePath)) { - return {}; - } - - const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); - const result: Record = {}; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - continue; - } - - result[trimmed.slice(0, eqIndex)] = trimmed.slice(eqIndex + 1); - } - - return result; -} - -/** - * Resolves a target path against the workspace root unless it's already - * absolute. - */ -export function resolveWorkspacePath(workspaceRoot: string, targetPath: string): string { - return path.isAbsolute(targetPath) ? targetPath : path.join(workspaceRoot, targetPath); -} - -/** - * Returns the value for `key`, or `defaultValue` if absent/empty. Use this - * when an optional setting has a reasonable default. - */ -export function readSetting(values: Record, key: string, defaultValue?: string): string | undefined { - return values[key] ?? defaultValue; -} - -/** - * Returns the value for `key`. Throws `errorMessage` if the value is missing - * or empty. Use this when a setting is required for the test run to work. - */ -export function requireSetting(values: Record, key: string, errorMessage: string): string { - const value = values[key]; - if (value) { - return value; - } - throw new Error(errorMessage); -} diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index 901f28bb7..150c45cc0 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,12 +1,8 @@ +export { END_USER_IDS, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed/index.ts'; export { actors, defaultActor, getActor, type TestActor, } from './test-actors.ts'; -export { - createMockEndUser, - getAllMockUsers, - type MockEndUser, -} from './user.test-data.ts'; export { generateObjectId } from './utils.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/user.test-data.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts similarity index 54% rename from packages/ocom-verification/verification-shared/src/test-data/user.test-data.ts rename to packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts index f03bb76ab..1141198e8 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/user.test-data.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts @@ -1,9 +1,9 @@ -import type { ActorDetails } from '../helpers/actor-helpers.ts'; -import { actors } from './test-actors.ts'; -import { generateObjectId } from './utils.ts'; +import type { ActorDetails } from '../../helpers/actor-helpers.ts'; +import { actors } from '../test-actors.ts'; -export interface MockEndUser { - id: string; +export interface EndUserSeedDocument { + _id: string; + userType: 'end-users'; externalId: string; displayName: string; email: string; @@ -19,15 +19,22 @@ export interface MockEndUser { }; accessBlocked: boolean; tags: string[]; - userType: 'end-users'; schemaVersion: string; createdAt: Date; updatedAt: Date; } -function createMockEndUserFromActor(actor: ActorDetails): MockEndUser { +export const END_USER_IDS = { + communityOwner: 'a00000000000000000000001', + communityMember: 'a00000000000000000000002', +} as const; + +export const endUsers: EndUserSeedDocument[] = [createEndUserSeedDocument(END_USER_IDS.communityOwner, actors.CommunityOwner), createEndUserSeedDocument(END_USER_IDS.communityMember, actors.CommunityMember)]; + +function createEndUserSeedDocument(id: string, actor: ActorDetails): EndUserSeedDocument { return { - id: generateObjectId(), + _id: id, + userType: 'end-users', externalId: actor.externalId, displayName: `${actor.givenName} ${actor.familyName}`.trim(), email: actor.email, @@ -43,28 +50,8 @@ function createMockEndUserFromActor(actor: ActorDetails): MockEndUser { }, accessBlocked: false, tags: [], - userType: 'end-users', schemaVersion: '1.0.0', createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), }; } - -const users = new Map( - Object.values(actors) - .filter((actor) => actor.externalId) - .map((actor) => { - const user = createMockEndUserFromActor(actor); - return [user.id, user]; - }), -); - -export function createMockEndUser(actor: ActorDetails): MockEndUser { - const user = createMockEndUserFromActor(actor); - users.set(user.id, user); - return user; -} - -export function getAllMockUsers(): MockEndUser[] { - return Array.from(users.values()); -} diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts new file mode 100644 index 000000000..2151ccda7 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -0,0 +1,2 @@ +export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; +export { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts new file mode 100644 index 000000000..bed6ce13a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts @@ -0,0 +1,43 @@ +import { type Document, MongoClient, ObjectId } from 'mongodb'; +import { endUsers } from './end-users.ts'; + +export interface MongoDBSeedContext { + connectionString: string; + dbName: string; +} + +export type MongoDBSeedDataFunction = (context: MongoDBSeedContext) => Promise; + +function toObjectId(id: string): ObjectId { + return new ObjectId(id); +} + +async function upsertSeedDocuments(client: MongoClient, dbName: string, collectionName: string, documents: Array): Promise { + await client + .db(dbName) + .collection(collectionName) + .bulkWrite( + documents.map((document) => ({ + replaceOne: { + filter: { _id: document._id }, + replacement: document, + upsert: true, + }, + })), + ); +} + +export async function seedDatabase(context: MongoDBSeedContext): Promise { + const client = new MongoClient(context.connectionString); + try { + await client.connect(); + + const users = endUsers.map((user) => ({ + ...user, + _id: toObjectId(user._id), + })); + await upsertSeedDocuments(client, context.dbName, 'users', users); + } finally { + await client.close(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0d71302f..3be5c052c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ catalogs: version: 4.1.2 overrides: - axios: 1.15.2 + axios: 1.16.0 follow-redirects: ^1.16.0 vite: 8.0.5 jiti: 2.6.1 @@ -109,6 +109,7 @@ overrides: '@ant-design/pro-layout>path-to-regexp': ^8.4.0 brace-expansion@1.1.12: 1.1.13 brace-expansion@5.0.4: 5.0.6 + brace-expansion@5.0.5: 5.0.6 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -119,7 +120,7 @@ overrides: svgo: ^3.3.3 yaml@2.8.2: 2.8.3 yauzl@3.2.0: 3.2.1 - qs: ^6.15.2 + qs: 6.15.2 express@4.22.1: 4.22.2 ajv@^6: 6.14.0 lodash: 4.18.1 @@ -130,6 +131,9 @@ overrides: webpack: ^5.105.4 webpack-dev-server: ^5.2.4 express-rate-limit: 8.5.1 + '@azure/ms-rest-js>uuid': ^3.4.0 + azurite>uuid: ^3.4.0 + ws@8.20.0: 8.20.1 playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 @@ -137,6 +141,8 @@ overrides: ip-address: ^10.1.1 fast-uri: ^3.1.2 '@babel/plugin-transform-modules-systemjs': 7.29.4 + shell-quote@<1.8.4: 1.8.4 + '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 ws: 8.20.1 shell-quote: 1.8.4 @@ -197,9 +203,6 @@ importers: '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.2(vitest@4.1.2) - azurite: - specifier: ^3.35.0 - version: 3.35.0 chrome-devtools-mcp: specifier: ^0.21.0 version: 0.21.0 @@ -297,6 +300,9 @@ importers: '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.2(vitest@4.1.2) + azurite: + specifier: ^3.35.0 + version: 3.35.0(@azure/core-client@1.10.1)(@types/node@24.10.1) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1247,6 +1253,9 @@ importers: '@ocom/service-mongoose': specifier: workspace:* version: link:../../ocom/service-mongoose + '@serenity-js/core': + specifier: 'catalog:' + version: 3.42.2 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2788,8 +2797,8 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@azure-rest/core-client@2.5.1': - resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==} + '@azure-rest/core-client@2.6.0': + resolution: {integrity: sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==} engines: {node: '>=20.0.0'} '@azure/abort-controller@1.1.0': @@ -2812,9 +2821,12 @@ packages: resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} engines: {node: '>=20.0.0'} - '@azure/core-http-compat@2.3.1': - resolution: {integrity: sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==} + '@azure/core-http-compat@2.4.0': + resolution: {integrity: sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==} engines: {node: '>=20.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 '@azure/core-lro@2.7.2': resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} @@ -2858,9 +2870,9 @@ packages: resolution: {integrity: sha512-0q5DL4uyR0EZ4RXQKD8MadGH6zTIcloUoS/RVbCpNpej4pwte0xpqYxk8K97Py2RiuUvI7F4GXpoT4046VfufA==} engines: {node: '>=14.0.0'} - '@azure/keyvault-common@2.0.0': - resolution: {integrity: sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==} - engines: {node: '>=18.0.0'} + '@azure/keyvault-common@2.1.0': + resolution: {integrity: sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==} + engines: {node: '>=20.0.0'} '@azure/keyvault-keys@4.10.0': resolution: {integrity: sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==} @@ -2889,9 +2901,9 @@ packages: resolution: {integrity: sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==} engines: {node: '>=16'} - '@azure/opentelemetry-instrumentation-azure-sdk@1.0.0-beta.9': - resolution: {integrity: sha512-gNCFokEoQQEkhu2T8i1i+1iW2o9wODn2slu5tpqJmjV1W7qf9dxVv6GNXW1P1WC8wMga8BCc2t/oMhOK3iwRQg==} - engines: {node: '>=18.0.0'} + '@azure/opentelemetry-instrumentation-azure-sdk@1.0.0': + resolution: {integrity: sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==} + engines: {node: '>=20.0.0'} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -4849,8 +4861,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@js-joda/core@5.6.5': - resolution: {integrity: sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==} + '@js-joda/core@5.7.0': + resolution: {integrity: sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==} '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} @@ -4945,6 +4957,10 @@ packages: resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.211.0': + resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.52.1': resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} engines: {node: '>=14'} @@ -5023,9 +5039,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.57.2': - resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==} - engines: {node: '>=14'} + '@opentelemetry/exporter-prometheus@0.217.0': + resolution: {integrity: sha512-U9MCXxJu0sBCh5aEkylYRR4xVIL8D1CW6dGwvYXbfFr0qveSorfD0XJchCAWoW6QfAAIcY/yxjf4Dj8OgkHBPw==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -5083,6 +5099,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.211.0': + resolution: {integrity: sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.52.1': resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} engines: {node: '>=14'} @@ -5131,8 +5153,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@2.2.0': - resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + '@opentelemetry/resources@2.7.1': + resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -5149,6 +5171,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.7.1': + resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-node@0.57.2': resolution: {integrity: sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==} engines: {node: '>=14'} @@ -5161,8 +5189,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.2.0': - resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + '@opentelemetry/sdk-trace-base@2.7.1': + resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -5173,8 +5201,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace-web@2.2.0': - resolution: {integrity: sha512-x/LHsDBO3kfqaFx5qSzBljJ5QHsRXrvS4MybBDy1k7Svidb8ZyIPudWVzj3s5LpPkYZIgi9e+7tdsNCnptoelw==} + '@opentelemetry/sdk-trace-web@2.7.1': + resolution: {integrity: sha512-K806OouCSOjMd8Nr7+ZCq3QT22tdAzzS/7h8vprfiKjkgFQ99/dvwU8d12WJANA6D5Qtme65hyBAqAu9CkQuxQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -6614,8 +6642,8 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@types/readable-stream@4.0.22': - resolution: {integrity: sha512-/FFhJpfCLAPwAcN3mFycNUa77ddnr8jTgF5VmSNetaemWB2cIlfCA9t0YTM3JAT0wOcv8D4tjPo7pkDhK3EJIg==} + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -7064,7 +7092,7 @@ packages: peerDependencies: '@apollo/client': '>=3' graphql: '>=0.11' - qs: ^6.15.2 + qs: 6.15.2 applicationinsights@2.9.8: resolution: {integrity: sha512-eB/EtAXJ6mDLLvHrtZj/7h31qUfnC2Npr2pHGqds5+1OP7BFLsn5us+HCkwTj7Q+1sHXujLphE5Cyvq5grtV6g==} @@ -7197,8 +7225,8 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} - axios@1.15.2: - resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} azurite@3.35.0: resolution: {integrity: sha512-GzKmi+/5U0baNRjEEVtBMLpLuIKEJ0uSh0VWBzOI4qe4f5ziJyoZQmcTO7QhxZTF6+rphj7TZS3PtJY7uiiacA==} @@ -7285,8 +7313,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bl@6.1.5: - resolution: {integrity: sha512-XylDt2P3JBttAwLpORq/hOEX9eJzP0r6Voa46C/WVvad8D1J0jW5876txB8FnzKtbdnU6X4Y1vOEvC6PllJrDg==} + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -7445,6 +7473,10 @@ packages: resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -7580,6 +7612,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} @@ -8245,8 +8280,8 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dottie@2.0.6: - resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} + dottie@2.0.7: + resolution: {integrity: sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. dset@3.1.4: @@ -8333,8 +8368,8 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-aggregate-error@1.0.14: @@ -9151,6 +9186,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -9196,6 +9235,9 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -9658,21 +9700,15 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - - jws@4.0.0: - resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} @@ -9943,10 +9979,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.3: - resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} - engines: {node: 20 || >=22} - lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -9954,12 +9986,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - - lru.min@1.1.3: - resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} luxon@3.7.2: @@ -10445,16 +10473,18 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + mysql2@3.22.3: + resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==} engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - named-placeholders@1.1.3: - resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} - engines: {node: '>=12.0.0'} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -10864,8 +10894,8 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-connection-string@2.9.1: - resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -11766,6 +11796,10 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} @@ -11887,8 +11921,8 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-buffer@5.1.2: @@ -11997,15 +12031,12 @@ packages: sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - sequelize-pool@7.1.0: resolution: {integrity: sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==} engines: {node: '>= 10.0.0'} - sequelize@6.37.7: - resolution: {integrity: sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==} + sequelize@6.37.8: + resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==} engines: {node: '>=10.0.0'} peerDependencies: ibm_db: '*' @@ -12254,9 +12285,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} srcset@4.0.0: resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} @@ -13018,8 +13049,8 @@ packages: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} - validator@13.15.23: - resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} value-equal@1.0.1: @@ -13294,8 +13325,8 @@ packages: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} - winston@3.18.3: - resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} wkx@0.5.0: @@ -13780,7 +13811,7 @@ snapshots: finalhandler: 2.1.1 graphql: 16.12.0 loglevel: 1.9.2 - lru-cache: 11.3.3 + lru-cache: 11.3.5 negotiator: 1.0.0 uuid: 11.1.1 whatwg-mimetype: 4.0.0 @@ -13864,7 +13895,7 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@azure-rest/core-client@2.5.1': + '@azure-rest/core-client@2.6.0': dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 @@ -13911,13 +13942,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/core-http-compat@2.3.1': + '@azure/core-http-compat@2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.22.2)': dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-client': 1.10.1 '@azure/core-rest-pipeline': 1.22.2 - transitivePeerDependencies: - - supports-color '@azure/core-lro@2.7.2': dependencies: @@ -13997,18 +14026,18 @@ snapshots: '@azure/msal-browser': 3.30.0 '@azure/msal-node': 2.16.3 events: 3.3.0 - jws: 4.0.0 + jws: 4.0.1 open: 8.4.2 stoppable: 1.1.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/keyvault-common@2.0.0': + '@azure/keyvault-common@2.1.0': dependencies: + '@azure-rest/core-client': 2.6.0 '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 '@azure/core-rest-pipeline': 1.22.2 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 @@ -14017,21 +14046,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/keyvault-keys@4.10.0': + '@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1)': dependencies: - '@azure-rest/core-client': 2.5.1 + '@azure-rest/core-client': 2.6.0 '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-http-compat': 2.3.1 + '@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.22.2) '@azure/core-lro': 2.7.2 '@azure/core-paging': 1.6.2 '@azure/core-rest-pipeline': 1.22.2 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 - '@azure/keyvault-common': 2.0.0 + '@azure/keyvault-common': 2.1.0 '@azure/logger': 1.3.0 tslib: 2.8.1 transitivePeerDependencies: + - '@azure/core-client' - supports-color '@azure/logger@1.3.0': @@ -14061,7 +14091,7 @@ snapshots: '@azure/ms-rest-js@1.11.2': dependencies: '@azure/core-auth': 1.10.1 - axios: 1.15.2 + axios: 1.16.0 form-data: 2.5.5 tough-cookie: 2.5.0 tslib: 1.14.1 @@ -14081,17 +14111,17 @@ snapshots: '@azure/msal-node@2.16.3': dependencies: '@azure/msal-common': 14.16.1 - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 uuid: 8.3.2 - '@azure/opentelemetry-instrumentation-azure-sdk@1.0.0-beta.9': + '@azure/opentelemetry-instrumentation-azure-sdk@1.0.0': dependencies: '@azure/core-tracing': 1.3.1 '@azure/logger': 1.3.0 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.200.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-web': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -16989,7 +17019,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@js-joda/core@5.6.5': {} + '@js-joda/core@5.7.0': {} '@js-sdsl/ordered-map@4.4.2': {} @@ -17104,6 +17134,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.211.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.52.1': dependencies: '@opentelemetry/api': 1.9.0 @@ -17199,12 +17233,13 @@ snapshots: '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.217.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': dependencies: @@ -17287,6 +17322,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.211.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -17352,10 +17396,10 @@ snapshots: '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 - '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0)': @@ -17371,6 +17415,12 @@ snapshots: '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node@0.57.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -17382,7 +17432,7 @@ snapshots: '@opentelemetry/exporter-metrics-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.217.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) @@ -17404,11 +17454,11 @@ snapshots: '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 - '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.0)': @@ -17421,11 +17471,11 @@ snapshots: '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) semver: 7.7.4 - '@opentelemetry/sdk-trace-web@2.2.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-web@2.7.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.25.1': {} @@ -18301,7 +18351,7 @@ snapshots: dependencies: '@serenity-js/core': 3.42.2 agent-base: 7.1.4 - axios: 1.15.2 + axios: 1.16.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 11.3.5 @@ -18316,7 +18366,7 @@ snapshots: '@serenity-js/core': 3.42.2 '@serenity-js/rest': 3.42.2 ansi-regex: 5.0.1 - axios: 1.15.2 + axios: 1.16.0 chalk: 4.1.2 find-java-home: 2.0.0 progress: 2.0.3 @@ -18367,7 +18417,7 @@ snapshots: '@sonar/scan@4.3.2': dependencies: adm-zip: 0.5.16 - axios: 1.15.2 + axios: 1.16.0 commander: 13.1.0 fs-extra: 11.3.2 hpagent: 1.2.0 @@ -18854,7 +18904,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/readable-stream@4.0.22': + '@types/readable-stream@4.0.23': dependencies: '@types/node': 24.10.1 @@ -19455,7 +19505,7 @@ snapshots: dependencies: '@azure/core-auth': 1.7.2 '@azure/core-rest-pipeline': 1.16.3 - '@azure/opentelemetry-instrumentation-azure-sdk': 1.0.0-beta.9 + '@azure/opentelemetry-instrumentation-azure-sdk': 1.0.0 '@microsoft/applicationinsights-web-snippet': 1.0.1 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) @@ -19510,7 +19560,7 @@ snapshots: array-buffer-byte-length: 1.0.2 call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -19598,7 +19648,7 @@ snapshots: axe-core@4.11.0: {} - axios@1.15.2: + axios@1.16.0: dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 @@ -19606,32 +19656,34 @@ snapshots: transitivePeerDependencies: - debug - azurite@3.35.0: + azurite@3.35.0(@azure/core-client@1.10.1)(@types/node@24.10.1): dependencies: '@azure/ms-rest-js': 1.11.2 applicationinsights: 2.9.8 args: 5.0.3 - axios: 1.15.2 + axios: 1.16.0 etag: 1.8.1 express: 4.22.2 fs-extra: 11.3.2 glob-to-regexp: 0.4.1 - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 lokijs: 1.5.12 morgan: 1.10.1 multistream: 2.1.1 - mysql2: 3.15.3 + mysql2: 3.22.3(@types/node@24.10.1) rimraf: 3.0.2 - sequelize: 6.37.7(mysql2@3.15.3)(tedious@16.7.1) + sequelize: 6.37.8(mysql2@3.22.3(@types/node@24.10.1))(tedious@16.7.1(@azure/core-client@1.10.1)) stoppable: 1.1.0 - tedious: 16.7.1 + tedious: 16.7.1(@azure/core-client@1.10.1) to-readable-stream: 2.1.0 tslib: 2.8.1 uri-templates: 0.2.0 uuid: 3.4.0 - winston: 3.18.3 + winston: 3.19.0 xml2js: 0.6.2 transitivePeerDependencies: + - '@azure/core-client' + - '@types/node' - applicationinsights-native-metrics - debug - ibm_db @@ -19712,9 +19764,9 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bl@6.1.5: + bl@6.1.6: dependencies: - '@types/readable-stream': 4.0.22 + '@types/readable-stream': 4.0.23 buffer: 6.0.3 inherits: 2.0.4 readable-stream: 4.7.0 @@ -19948,6 +20000,13 @@ snapshots: get-intrinsic: 1.3.0 set-function-length: 1.2.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -20099,6 +20158,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} classnames@2.5.1: {} @@ -20762,7 +20823,7 @@ snapshots: dotenv@16.6.1: {} - dottie@2.0.6: {} + dottie@2.0.7: {} dset@3.1.4: {} @@ -20842,7 +20903,7 @@ snapshots: dependencies: stackframe: 1.3.4 - es-abstract@1.24.0: + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -20884,7 +20945,7 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 @@ -20903,7 +20964,7 @@ snapshots: dependencies: define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 function-bind: 1.1.2 globalthis: 1.0.4 @@ -21966,6 +22027,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + icss-utils@5.1.0(postcss@8.5.10): dependencies: postcss: 8.5.10 @@ -21999,6 +22064,13 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + import-lazy@4.0.0: {} imurmurhash@0.1.4: {} @@ -22442,9 +22514,9 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonwebtoken@9.0.2: + jsonwebtoken@9.0.3: dependencies: - jws: 3.2.2 + jws: 4.0.1 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -22455,24 +22527,13 @@ snapshots: ms: 2.1.3 semver: 7.7.4 - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - - jws@4.0.0: + jws@4.0.1: dependencies: jwa: 2.0.1 safe-buffer: 5.2.1 @@ -22717,17 +22778,13 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.3: {} - lru-cache@11.3.5: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - - lru.min@1.1.3: {} + lru.min@1.1.4: {} luxon@3.7.2: {} @@ -23487,17 +23544,17 @@ snapshots: mute-stream@0.0.8: {} - mysql2@3.15.3: + mysql2@3.22.3(@types/node@24.10.1): dependencies: + '@types/node': 24.10.1 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.2 long: 5.3.2 - lru.min: 1.1.3 - named-placeholders: 1.1.3 - seq-queue: 0.0.5 - sqlstring: 2.3.3 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 mz@2.7.0: dependencies: @@ -23505,9 +23562,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - named-placeholders@1.1.3: + named-placeholders@1.1.6: dependencies: - lru-cache: 7.18.3 + lru.min: 1.1.4 nanoid@3.3.11: {} @@ -23986,7 +24043,7 @@ snapshots: pend@1.2.0: {} - pg-connection-string@2.9.1: {} + pg-connection-string@2.13.0: {} picocolors@1.1.1: {} @@ -24867,7 +24924,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -25044,6 +25101,13 @@ snapshots: transitivePeerDependencies: - supports-color + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + require-like@0.1.2: {} requires-port@1.0.0: {} @@ -25187,9 +25251,9 @@ snapshots: dependencies: tslib: 2.8.1 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -25301,31 +25365,29 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 - seq-queue@0.0.5: {} - sequelize-pool@7.1.0: {} - sequelize@6.37.7(mysql2@3.15.3)(tedious@16.7.1): + sequelize@6.37.8(mysql2@3.22.3(@types/node@24.10.1))(tedious@16.7.1(@azure/core-client@1.10.1)): dependencies: '@types/debug': 4.1.12 '@types/validator': 13.15.10 debug: 4.4.3(supports-color@8.1.1) - dottie: 2.0.6 + dottie: 2.0.7 inflection: 1.13.4 lodash: 4.18.1 moment: 2.30.1 moment-timezone: 0.5.48 - pg-connection-string: 2.9.1 + pg-connection-string: 2.13.0 retry-as-promised: 7.1.1 semver: 7.7.4 sequelize-pool: 7.1.0 toposort-class: 1.0.1 uuid: 8.3.2 - validator: 13.15.23 + validator: 13.15.35 wkx: 0.5.0 optionalDependencies: - mysql2: 3.15.3 - tedious: 16.7.1 + mysql2: 3.22.3(@types/node@24.10.1) + tedious: 16.7.1(@azure/core-client@1.10.1) transitivePeerDependencies: - supports-color @@ -25585,7 +25647,7 @@ snapshots: sprintf-js@1.1.3: {} - sqlstring@2.3.3: {} + sql-escaper@1.3.3: {} srcset@4.0.0: {} @@ -25696,7 +25758,7 @@ snapshots: call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 @@ -25875,12 +25937,12 @@ snapshots: - bare-abort-controller - react-native-b4a - tedious@16.7.1: + tedious@16.7.1(@azure/core-client@1.10.1): dependencies: '@azure/identity': 3.4.2 - '@azure/keyvault-keys': 4.10.0 - '@js-joda/core': 5.6.5 - bl: 6.1.5 + '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) + '@js-joda/core': 5.7.0 + bl: 6.1.6 es-aggregate-error: 1.0.14 iconv-lite: 0.6.3 js-md4: 0.3.2 @@ -25889,6 +25951,7 @@ snapshots: node-abort-controller: 3.1.1 sprintf-js: 1.1.3 transitivePeerDependencies: + - '@azure/core-client' - supports-color terser-webpack-plugin@5.3.14(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)): @@ -26368,7 +26431,7 @@ snapshots: validate-npm-package-name@7.0.2: {} - validator@13.15.23: {} + validator@13.15.35: {} value-equal@1.0.1: {} @@ -26753,7 +26816,7 @@ snapshots: readable-stream: 3.6.2 triple-beam: 1.4.1 - winston@3.18.3: + winston@3.19.0: dependencies: '@colors/colors': 1.6.0 '@dabh/diagnostics': 2.0.8 @@ -26808,18 +26871,18 @@ snapshots: xml-js@1.6.11: dependencies: - sax: 1.4.3 + sax: 1.5.0 xml-name-validator@5.0.0: {} xml2js@0.4.23: dependencies: - sax: 1.4.3 + sax: 1.5.0 xmlbuilder: 11.0.1 xml2js@0.6.2: dependencies: - sax: 1.4.3 + sax: 1.5.0 xmlbuilder: 11.0.1 xmlbuilder@11.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 423bdb23f..9238556fd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,7 +59,7 @@ allowBuilds: snyk: true overrides: - axios: 1.15.2 + axios: 1.16.0 follow-redirects: ^1.16.0 vite: "catalog:" jiti: 2.6.1 @@ -67,6 +67,7 @@ overrides: '@ant-design/pro-layout>path-to-regexp': ^8.4.0 'brace-expansion@1.1.12': 1.1.13 'brace-expansion@5.0.4': 5.0.6 + 'brace-expansion@5.0.5': 5.0.6 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -77,7 +78,7 @@ overrides: svgo: ^3.3.3 'yaml@2.8.2': 2.8.3 'yauzl@3.2.0': 3.2.1 - qs: ^6.15.2 + qs: 6.15.2 'express@4.22.1': 4.22.2 'ajv@^6': 6.14.0 lodash: 4.18.1 @@ -88,6 +89,9 @@ overrides: webpack: ^5.105.4 webpack-dev-server: ^5.2.4 express-rate-limit: 8.5.1 + '@azure/ms-rest-js>uuid': '^3.4.0' + 'azurite>uuid': '^3.4.0' + 'ws@8.20.0': 8.20.1 playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 @@ -95,6 +99,8 @@ overrides: ip-address: ^10.1.1 fast-uri: ^3.1.2 '@babel/plugin-transform-modules-systemjs': 7.29.4 + 'shell-quote@<1.8.4': 1.8.4 + '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 ws: 8.20.1 shell-quote: 1.8.4 diff --git a/readme.md b/readme.md index b1fcdbd19..886223cc6 100644 --- a/readme.md +++ b/readme.md @@ -4,8 +4,9 @@ Domain-driven architecture for Azure Functions with GraphQL/REST, MongoDB (Mongo ## Introduction + [Getting Started](https://developers.cellixjs.org/docs/intro): -Our Docusaurus website will help you get started in running and contributing to CellixJS +Our Docusaurus website will help you get started with running and contributing to CellixJS ## Project Status @@ -26,7 +27,6 @@ Our Docusaurus website will help you get started in running and contributing to ## Developer usage - - Full local dev (builds, starts the portless HTTPS proxy, starts Azurite, and runs the app-level dev servers): ```bash diff --git a/scripts/local-dev/dev-process-exit.mjs b/scripts/local-dev/dev-process-exit.mjs new file mode 100644 index 000000000..ed0232915 --- /dev/null +++ b/scripts/local-dev/dev-process-exit.mjs @@ -0,0 +1,25 @@ +/** @typedef {import('node:child_process').ChildProcess} ChildProcess */ + +/** + * @param {NodeJS.Signals | null | undefined} signal + * @param {number | null | undefined} code + * @returns {boolean} + */ +export const isGracefulInterruptExit = (signal, code) => signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGQUIT' || code === 130 || code === 143; + +/** + * Wires a spawned dev child process to forward its exit status to the parent, + * treating Turbo's interrupt signals as graceful exits. Every `start-dev.mjs` + * runner ends with the same handler, so this is the single source of truth. + * @param {ChildProcess} child + * @returns {void} + */ +export function forwardChildExit(child) { + child.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + process.exitCode = 0; + return; + } + process.exitCode = code ?? 1; + }); +} diff --git a/scripts/local-dev/portless-hostnames.mjs b/scripts/local-dev/portless-hostnames.mjs new file mode 100644 index 000000000..976c82e92 --- /dev/null +++ b/scripts/local-dev/portless-hostnames.mjs @@ -0,0 +1,97 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * @typedef {Record} DotEnvValues + * @typedef {object} PortlessHostnames + * @property {string} uiCommunity + * @property {string} uiStaff + * @property {string} api + * @property {string} mockAuth + * @property {string} docs + */ + +const PORTLESS_PORT = 1355; +const scriptDir = fileURLToPath(new URL('.', import.meta.url)); +const workspaceRoot = resolve(scriptDir, '../..'); + +/** + * @param {string} filePath + * @returns {DotEnvValues} + */ +function readDotEnv(filePath) { + if (!existsSync(filePath)) return {}; + /** @type {DotEnvValues} */ + const result = {}; + for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } + return result; +} + +/** + * @param {string} url + * @returns {string | null} + */ +function hostnameFrom(url) { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +/** + * @param {string} key + * @param {DotEnvValues} values + * @returns {string | null} + */ +function hostnameFor(key, values) { + return hostnameFrom(process.env[key] ?? values[key] ?? ''); +} + +function applyWorktreeSuffix(hostname, worktreeName) { + if (!worktreeName) return hostname; + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +export function getHostnames() { + const uiEnv = readDotEnv(resolve(workspaceRoot, 'apps/ui-community/.env')); + const staffEnv = readDotEnv(resolve(workspaceRoot, 'apps/ui-staff/.env')); + const wt = process.env.WORKTREE_NAME ?? ''; + + const uiCommunity = hostnameFor('VITE_APP_UI_COMMUNITY_BASE_URL', uiEnv); + const api = hostnameFor('VITE_COMMON_API_ENDPOINT', uiEnv); + const mockAuth = hostnameFor('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', uiEnv); + const uiStaff = hostnameFor('VITE_APP_UI_STAFF_AAD_REDIRECT_URI', staffEnv); + + if (!uiCommunity || !api || !mockAuth || !uiStaff) { + throw new Error('portless-hostnames: could not derive all hostnames from .env files. ' + 'Ensure apps/ui-community/.env and apps/ui-staff/.env are present.'); + } + const docs = `docs.${uiCommunity}`; + + return { + uiCommunity: applyWorktreeSuffix(uiCommunity, wt), + uiStaff: applyWorktreeSuffix(uiStaff, wt), + api: applyWorktreeSuffix(api, wt), + mockAuth: applyWorktreeSuffix(mockAuth, wt), + docs: applyWorktreeSuffix(docs, wt), + }; +} + +/** + * Builds a full portless-proxied URL for the given hostname and optional path. + * @param {string} hostname + * @param {string} [path] + * @returns {string} + */ +export function buildPortlessUrl(hostname, path = '') { + return `https://${hostname}:${PORTLESS_PORT}${path}`; +} + +export { PORTLESS_PORT }; diff --git a/scripts/local-dev/vite-dev-args.mjs b/scripts/local-dev/vite-dev-args.mjs new file mode 100644 index 000000000..dcc39bc77 --- /dev/null +++ b/scripts/local-dev/vite-dev-args.mjs @@ -0,0 +1,26 @@ +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function isE2E(env = process.env) { + return ['1', 'true', 'yes'].includes((env.E2E ?? '').toLowerCase()); +} + +/** + * @param {{ host?: string; port?: string; env?: NodeJS.ProcessEnv }} [options] + * @returns {string[]} + */ +export function buildViteArgs(options = {}) { + const { host = '127.0.0.1', port, env = process.env } = options; + const args = ['--host', host]; + if (port) { + args.push('--port', port); + } + + const viteMode = env.E2E_VITE_MODE ?? (isE2E(env) || env.TF_BUILD ? 'e2e' : undefined); + if (viteMode) { + args.push('--mode', viteMode); + } + + return args; +} diff --git a/scripts/local-dev/worktree-ports.mjs b/scripts/local-dev/worktree-ports.mjs new file mode 100644 index 000000000..de11700d6 --- /dev/null +++ b/scripts/local-dev/worktree-ports.mjs @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * @typedef {Record} SettingsValues + * @typedef {{ blob: number, queue: number, table: number }} AzuritePorts + */ + +/** + * Worktree-scoped port computation for service isolation. + * + * When WORKTREE_NAME is set, each worktree gets a deterministic port offset + * so MongoDB and Azurite instances don't collide between worktrees. + * + * Default worktree (no WORKTREE_NAME): uses base ports (50000, 10000–10002). + * Named worktree: base + deterministic offset derived from the name's hash. + * + * Collision safety: the unset case always returns 0, and any named worktree + * always returns ≥ 100, so the default worktree can never collide with a + * named one. With 49 buckets the chance of two *named* worktrees colliding + * is ~2% per pair — acceptable for the typical 1–3 concurrent worktrees. + */ + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const apiLocalSettingsPaths = [path.join(workspaceRoot, 'apps', 'api', 'deploy', 'local.settings.json'), path.join(workspaceRoot, 'apps', 'api', 'local.settings.json')]; +/** @type {SettingsValues | undefined} */ +let apiLocalSettingsValues; + +/** + * @param {string} name + * @param {SettingsValues} [values] + * @returns {string | undefined} + */ +function getSetting(name, values) { + return process.env[name] ?? values?.[name] ?? getApiLocalSetting(name); +} + +/** + * @param {string} name + * @returns {string | undefined} + */ +function getApiLocalSetting(name) { + apiLocalSettingsValues ??= readApiLocalSettingsValues(); + return apiLocalSettingsValues[name]; +} + +/** + * @returns {SettingsValues} + */ +function readApiLocalSettingsValues() { + for (const settingsPath of apiLocalSettingsPaths) { + if (!fs.existsSync(settingsPath)) continue; + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + return settings.Values ?? {}; + } + return {}; +} + +/** + * Returns a deterministic port offset in the range [100, 4900] (step 100) + * for the current worktree. Returns 0 when WORKTREE_NAME is not set. + * @returns {number} + */ +export function getWorktreePortOffset() { + const name = process.env.WORKTREE_NAME; + if (!name) return 0; + let hash = 0; + for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; + return ((Math.abs(hash) % 49) + 1) * 100; +} + +/** + * MongoDB port for the current worktree. + * @returns {number} + */ +export function getMongoPort() { + return 50000 + getWorktreePortOffset(); +} + +/** + * Azurite blob/queue/table ports for the current worktree. + * @returns {AzuritePorts} + */ +export function getAzuritePorts() { + const offset = getWorktreePortOffset(); + return { + blob: 10000 + offset, + queue: 10001 + offset, + table: 10002 + offset, + }; +} + +/** + * Azurite connection string for worktree-specific ports. + * Returns `UseDevelopmentStorage=true` for the default worktree (port 10000). + * @param {SettingsValues} [values] + * @returns {string} + */ +export function getAzuriteConnectionString(values) { + const ports = getAzuritePorts(); + if (ports.blob === 10000) return 'UseDevelopmentStorage=true'; + const accountName = getSetting('STORAGE_ACCOUNT_NAME', values); + const accountKey = getSetting('STORAGE_ACCOUNT_KEY', values); + if (!accountName || !accountKey) { + throw new Error('[worktree-ports] STORAGE_ACCOUNT_NAME and STORAGE_ACCOUNT_KEY must be set to build a worktree Azurite connection string'); + } + return [ + 'DefaultEndpointsProtocol=http', + `AccountName=${accountName}`, + `AccountKey=${accountKey}`, + `BlobEndpoint=http://127.0.0.1:${ports.blob}/${accountName}`, + `QueueEndpoint=http://127.0.0.1:${ports.queue}/${accountName}`, + `TableEndpoint=http://127.0.0.1:${ports.table}/${accountName}`, + ].join(';'); +} + +/** + * MongoDB connection string with the worktree-specific port patched in. + * Reads COSMOSDB_CONNECTION_STRING from env or local.settings.json and replaces + * the host:port segment. + * @returns {string} + */ +export function getMongoConnectionString() { + const base = getSetting('COSMOSDB_CONNECTION_STRING'); + if (!base) throw new Error('[worktree-ports] COSMOSDB_CONNECTION_STRING must be set'); + const url = new URL(base); + url.port = String(getMongoPort()); + return url.toString(); +} diff --git a/sonar-project.properties b/sonar-project.properties index e5b7d641a..0cffddb63 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -95,7 +95,7 @@ sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/* sonar.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/** # Coverage exclusions -sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx +sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,scripts/local-dev/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx # CPD (code duplication) exclusions sonar.cpd.exclusions=**/*.test.ts,**/*.generated.ts,**/*.generated.tsx,packages/cellix/archunit-tests/src/test-suites/**,packages/cellix/archunit-tests/src/fixtures/** diff --git a/turbo.json b/turbo.json index 3f1ccbe30..6c391b388 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "ui": "tui", + "globalPassThroughEnv": ["WORKTREE_NAME"], "futureFlags": { "affectedUsingTaskInputs": true, "watchUsingTaskInputs": true @@ -108,6 +109,12 @@ "cache": false, "persistent": true }, + "dev:worktree": { + "description": "Starts dev servers with worktree-scoped portless hostnames for git worktree isolation", + "dependsOn": ["^build"], + "cache": false, + "persistent": true + }, "azurite": { "description": "Starts the Azurite storage emulator", "cache": false,