diff --git a/.snyk b/.snyk index d1b05b92a..12ae54c5b 100644 --- a/.snyk +++ b/.snyk @@ -96,4 +96,3 @@ ignore: 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' - diff --git a/apps/api/start-dev.mjs b/apps/api/start-dev.mjs index 7c705d8c7..edcb6a350 100644 --- a/apps/api/start-dev.mjs +++ b/apps/api/start-dev.mjs @@ -18,7 +18,10 @@ const childEnv = { NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --use-system-ca`.trim(), }; -const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort], { +// `--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. +const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort, '--cors', '*'], { stdio: 'inherit', env: childEnv, }); diff --git a/build-pipeline/core/monorepo-build-stage.yml b/build-pipeline/core/monorepo-build-stage.yml index d012c6cf6..bfecb863f 100644 --- a/build-pipeline/core/monorepo-build-stage.yml +++ b/build-pipeline/core/monorepo-build-stage.yml @@ -243,7 +243,7 @@ stages: echo "Testing affected packages only (PR/branch build)..." export TURBO_SCM_BASE="origin/$(System.PullRequest.TargetBranch)" export TURBO_CONCURRENCY=4 - pnpm run test:coverage --affected + pnpm run test:coverage:affected pnpm run merge-lcov-reports else echo "Testing all packages (main branch build)..." @@ -281,6 +281,22 @@ stages: ${{ each pair in parameters.buildEnvSettings }}: ${{ pair.key }}: ${{ pair.value }} + # Run E2E tests + - task: Bash@3 + displayName: 'Run E2E tests' + inputs: + targetType: 'inline' + script: | + set -euo pipefail + export NODE_OPTIONS=--max_old_space_size=16384 + export PLAYWRIGHT_BROWSERS_PATH="$(PLAYWRIGHT_BROWSERS_PATH)" + echo "Running E2E tests..." + pnpm run test:e2e:ci + workingDirectory: '' + env: + TURBO_TELEMETRY_DISABLED: 1 + PLAYWRIGHT_BROWSERS_PATH: $(PLAYWRIGHT_BROWSERS_PATH) + # Audit unused dependencies with knip (after packages are built) - task: Bash@3 displayName: 'Audit unused dependencies with knip' diff --git a/build-pipeline/scripts/merge-coverage.js b/build-pipeline/scripts/merge-coverage.js index 349172dd8..50ba79dbe 100755 --- a/build-pipeline/scripts/merge-coverage.js +++ b/build-pipeline/scripts/merge-coverage.js @@ -11,105 +11,104 @@ const __dirname = dirname(__filename); * Simple LCOV merger that combines multiple lcov.info files */ function processLcovContent(content, packagePath) { - const lines = content.split('\n'); - const processedLines = []; - - for (const line of lines) { - if (line.startsWith('SF:')) { - // Extract the file path after 'SF:' - const filePath = line.substring(3); - // Prefix with package path, ensuring no double slashes - const prefixedPath = path.join(packagePath, filePath).replaceAll('\\', '/'); - processedLines.push(`SF:${prefixedPath}`); - } else { - processedLines.push(line); - } - } - - return processedLines.join('\n'); + const lines = content.split('\n'); + const processedLines = []; + + for (const line of lines) { + if (line.startsWith('SF:')) { + // Extract the file path after 'SF:' + const filePath = line.substring(3); + // Prefix with package path, ensuring no double slashes + const prefixedPath = path.join(packagePath, filePath).replaceAll('\\', '/'); + processedLines.push(`SF:${prefixedPath}`); + } else { + processedLines.push(line); + } + } + + return processedLines.join('\n'); } function mergeLcovFiles() { - const rootDir = process.cwd(); - const outputFile = path.join(rootDir, 'coverage', 'lcov.info'); - - // Create output directory - const outputDir = path.dirname(outputFile); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Find all lcov.info files - const lcovFiles = []; - - function findLcovFiles(dir) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && entry.name !== '.git') { - findLcovFiles(fullPath); - } - } else if (entry.name === 'lcov.info' && fullPath.includes('/coverage/')) { - lcovFiles.push(fullPath); - } - } - } - - // Search in apps and packages directories - const searchDirs = ['apps', 'packages'].filter(dir => - fs.existsSync(path.join(rootDir, dir)) - ); - - for (const dir of searchDirs) { - findLcovFiles(path.join(rootDir, dir)); - } - - console.log(`Found ${lcovFiles.length} LCOV files:`); - lcovFiles.forEach(file => console.log(` - ${file}`)); - - if (lcovFiles.length === 0) { - console.log('No LCOV files found. Creating empty coverage file.'); - fs.writeFileSync(outputFile, ''); - return; - } - - // Merge all LCOV files - let mergedContent = ''; - - for (const lcovFile of lcovFiles) { - try { - const content = fs.readFileSync(lcovFile, 'utf8'); - if (content.trim()) { - // Compute the package path relative to monorepo root - const packageDir = path.dirname(path.dirname(lcovFile)); // Go up from coverage/ to package/ - const packagePath = path.relative(rootDir, packageDir); - - // Process the LCOV content to prefix SF: paths - const processedContent = processLcovContent(content, packagePath); - - mergedContent += processedContent; - if (!processedContent.endsWith('\n')) { - mergedContent += '\n'; - } - } - } catch (error) { - console.warn(`Warning: Could not read ${lcovFile}: ${error.message}`); - } - } - - // Write merged content - fs.writeFileSync(outputFile, mergedContent); - - console.log(`Merged coverage report written to: ${outputFile}`); - console.log(`Total size: ${mergedContent.length} characters`); - - // Count records - const records = (mergedContent.match(/end_of_record/g) || []).length; - console.log(`Coverage records: ${records}`); + const rootDir = process.cwd(); + const outputFile = path.join(rootDir, 'coverage', 'lcov.info'); + + // Create output directory + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Find all lcov.info files + const lcovFiles = []; + + function findLcovFiles(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!['node_modules', '.git', '.turbo', 'dist', 'build'].includes(entry.name)) { + findLcovFiles(fullPath); + } + } else if (entry.name === 'lcov.info' && fullPath.replaceAll('\\', '/').includes('/coverage/')) { + if (fullPath !== outputFile) { + lcovFiles.push(fullPath); + } + } + } + } + + const searchDirs = ['apps', 'packages'].filter((dir) => fs.existsSync(path.join(rootDir, dir))); + + for (const dir of searchDirs) { + findLcovFiles(path.join(rootDir, dir)); + } + + console.log(`Found ${lcovFiles.length} LCOV files:`); + lcovFiles.forEach((file) => console.log(` - ${file}`)); + + if (lcovFiles.length === 0) { + console.log('No LCOV files found. Creating empty coverage file.'); + fs.writeFileSync(outputFile, ''); + return; + } + + // Merge all LCOV files + let mergedContent = ''; + + for (const lcovFile of lcovFiles) { + try { + const content = fs.readFileSync(lcovFile, 'utf8'); + if (content.trim()) { + // Compute the package path relative to monorepo root + const packageDir = path.dirname(path.dirname(lcovFile)); // Go up from coverage/ to package/ + const packagePath = path.relative(rootDir, packageDir); + + // Process the LCOV content to prefix SF: paths + const processedContent = processLcovContent(content, packagePath); + + mergedContent += processedContent; + if (!processedContent.endsWith('\n')) { + mergedContent += '\n'; + } + } + } catch (error) { + console.warn(`Warning: Could not read ${lcovFile}: ${error.message}`); + } + } + + // Write merged content + fs.writeFileSync(outputFile, mergedContent); + + console.log(`Merged coverage report written to: ${outputFile}`); + console.log(`Total size: ${mergedContent.length} characters`); + + // Count records + const records = (mergedContent.match(/end_of_record/g) || []).length; + console.log(`Coverage records: ${records}`); } // Run the merger -mergeLcovFiles(); \ No newline at end of file +mergeLcovFiles(); diff --git a/package.json b/package.json index 12aea8a26..b5fb11076 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,12 @@ "start-emulator:auth-server": "pnpm --filter @cellix/mock-oauth2-server start", "test:all": "turbo run test:all", "test:arch": "turbo run test:arch", - "test:coverage": "turbo run test:coverage", + "test:coverage": "turbo run test:coverage test:coverage:acceptance", + "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:acceptance": "turbo run test:acceptance --filter=@ocom-verification/acceptance-api --filter=@ocom-verification/acceptance-ui", + "test:e2e:ci": "turbo run test:e2e:ci --filter=@ocom-verification/e2e-tests", + "test:acceptance": "turbo run test:acceptance", "merge-lcov-reports": "node build-pipeline/scripts/merge-coverage.js", "test:integration": "turbo run test:integration", "test:serenity": "turbo run test:serenity", @@ -46,7 +48,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 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 && 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", diff --git a/packages/cellix/archunit-tests/package.json b/packages/cellix/archunit-tests/package.json index 96dcca505..3c1e6ba9c 100644 --- a/packages/cellix/archunit-tests/package.json +++ b/packages/cellix/archunit-tests/package.json @@ -43,8 +43,8 @@ }, "scripts": { "prebuild": "biome lint", - "build": "tsgo --build", - "watch": "tsgo --watch", + "build": "tsc --build", + "watch": "tsc --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 1a4bf4794..dffd74aee 100644 --- a/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts +++ b/packages/cellix/server-mongodb-memory-mock-seedwork/src/index.ts @@ -25,7 +25,12 @@ export async function startMongoMemoryReplicaSet(config: MongoMemoryReplicaSetCo count: 1, storageEngine: 'wiredTiger', }, - instanceOpts: [{ port: config.port }], + instanceOpts: [ + { + port: config.port, + args: ['--setParameter', 'maxTransactionLockRequestTimeoutMillis=5000'], + }, + ], }); const uri = replicaSet.getUri(config.dbName); diff --git a/packages/cellix/server-oauth2-mock-seedwork/package.json b/packages/cellix/server-oauth2-mock-seedwork/package.json index ef0015814..68da0838b 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/package.json +++ b/packages/cellix/server-oauth2-mock-seedwork/package.json @@ -18,7 +18,7 @@ "test:watch": "vitest" }, "dependencies": { - "express": "^4.22.0", + "express": "^4.22.2", "express-rate-limit": "^8.5.1", "jose": "^5.9.6" }, diff --git a/packages/ocom-verification/acceptance-api/.c8rc.json b/packages/ocom-verification/acceptance-api/.c8rc.json index 429ff417d..a740072f6 100644 --- a/packages/ocom-verification/acceptance-api/.c8rc.json +++ b/packages/ocom-verification/acceptance-api/.c8rc.json @@ -1,7 +1,7 @@ { - "all": true, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "allowExternal": true, + "include": ["**/ocom/application-services/dist/**", "**/ocom/domain/dist/**", "**/ocom/graphql/dist/**", "**/ocom/persistence/dist/**", "**/ocom/data-sources-mongoose-models/dist/**"], + "exclude": ["**/node_modules/**"], "reporter": ["text", "lcovonly"], "report-dir": "coverage" } diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 6e246bde5..5887447d4 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -7,6 +7,7 @@ "scripts": { "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --format json:./reports/cucumber-report-api.json", "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --allowExternal -- cucumber-js --format json:./reports/cucumber-report-api.json", + "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 -- cucumber-js --format json:./reports/cucumber-report-api.json", "verification:test:coverage:report": "c8 report --allowExternal", "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" }, 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 new file mode 100644 index 000000000..8ab66301e --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -0,0 +1,53 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; + +interface HeaderApiNotes { + identityProviderUnreachable: boolean; + signinRedirectInvoked: boolean; + fallbackTriggered: boolean; +} + +let lastActorName = 'Alex'; + +// Header sign-in is a UI-only concern. These step bindings keep the shared +// feature in sync across layers without exercising any API behaviour. + +Given('{word} visits the community site', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('identityProviderUnreachable', false), notes().set('signinRedirectInvoked', false), notes().set('fallbackTriggered', false)); +}); + +Given('{word} visits the staff site', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('identityProviderUnreachable', false), notes().set('signinRedirectInvoked', false), notes().set('fallbackTriggered', false)); +}); + +Given('the identity provider is unreachable', async () => { + const actor = actorCalled(lastActorName); + await actor.attemptsTo(notes().set('identityProviderUnreachable', true)); +}); + +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)); +}); + +Then('{word} is taken to the sign-in flow', async (actorName: string) => { + const actor = actorCalled(actorName); + const invoked = await actor.answer(notes().get('signinRedirectInvoked')); + if (!invoked) { + throw new Error(`Expected ${actorName} to be taken to the sign-in flow, but the sign-in handler was not invoked`); + } +}); + +Then('{word} can still reach the sign-in page', async (actorName: string) => { + const actor = actorCalled(actorName); + const fallback = await actor.answer(notes().get('fallbackTriggered')); + if (!fallback) { + throw new Error(`Expected ${actorName} to reach the sign-in page via the fallback path, but the fallback was not triggered`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/index.ts new file mode 100644 index 000000000..c137e8181 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Authentication context step definitions +import './header-login.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts index 7e774dc2c..ca8096e34 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts @@ -33,7 +33,7 @@ function createNoOpApolloServerService(): ServiceApolloServer; + } as unknown as ServiceApolloServer>; } export function createMockApplicationServicesFactory(serviceMongoose: ServiceMongoose): ApplicationServicesFactory { diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts index dc1055837..9e919f49d 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts @@ -1,11 +1,13 @@ import type { IWorld } from '@cucumber/cucumber'; import { After, AfterAll, Before, setDefaultTimeout } from '@cucumber/cucumber'; +import { getTimeout } from '@ocom-verification/verification-shared/settings'; import { isAgent } from 'std-env'; import { type CellixApiWorld, stopSharedServers } from '../../world.ts'; let printedSuiteHeader = false; -setDefaultTimeout(120_000); +/** Default scenario timeout from centralized configuration */ +setDefaultTimeout(getTimeout('scenario')); Before(async function (this: IWorld) { const world = this as IWorld & CellixApiWorld; 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 cdc91878a..ce5409030 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 @@ -60,3 +60,8 @@ export async function ensureApiServers(): Promise { await graphQLServer.start(); apiUrl = graphQLServer.getUrl(); } + +export async function resetMongoForScenario(): Promise { + if (!mongoDBServer) return; + await mongoDBServer.resetForScenario(); +} diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index 695086bf6..395b3f752 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -4,3 +4,4 @@ */ import '../contexts/community/step-definitions/index.ts'; +import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index b472164d3..757aebe3b 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -13,6 +13,7 @@ export class CellixApiWorld extends World { async init(): Promise { await infra.ensureApiServers(); + await infra.resetMongoForScenario(); const { apiUrl } = infra.getState(); if (apiUrl) { diff --git a/packages/ocom-verification/acceptance-api/turbo.json b/packages/ocom-verification/acceptance-api/turbo.json index 93d16a120..bd3626a34 100644 --- a/packages/ocom-verification/acceptance-api/turbo.json +++ b/packages/ocom-verification/acceptance-api/turbo.json @@ -1,6 +1,12 @@ { "extends": ["//"], "tasks": { + "test:coverage:acceptance": { + "dependsOn": ["^build"], + "inputs": ["src/**/*.ts", "cucumber.js", "package.json", ".c8rc.json"], + "outputs": ["coverage/**"], + "cache": false + }, "test:acceptance": { "dependsOn": ["^build"], "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], diff --git a/packages/ocom-verification/acceptance-ui/.c8rc.json b/packages/ocom-verification/acceptance-ui/.c8rc.json index a76d872cb..52ac68097 100644 --- a/packages/ocom-verification/acceptance-ui/.c8rc.json +++ b/packages/ocom-verification/acceptance-ui/.c8rc.json @@ -1,7 +1,33 @@ { "all": true, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["node_modules", "dist"], + "allowExternal": true, + "src": [ + "../../ocom/ui-community-route-accounts/src", + "../../ocom/ui-community-route-admin/src", + "../../ocom/ui-community-route-root/src", + "../../ocom/ui-community-shared/src", + "../../ocom/ui-shared/src", + "../../ocom/ui-staff-route-community-management/src", + "../../ocom/ui-staff-route-finance/src", + "../../ocom/ui-staff-route-root/src", + "../../ocom/ui-staff-route-tech-admin/src", + "../../ocom/ui-staff-route-user-management/src", + "../../ocom/ui-staff-shared/src" + ], + "include": [ + "**/ocom/ui-community-route-accounts/src/**", + "**/ocom/ui-community-route-admin/src/**", + "**/ocom/ui-community-route-root/src/**", + "**/ocom/ui-community-shared/src/**", + "**/ocom/ui-shared/src/**", + "**/ocom/ui-staff-route-community-management/src/**", + "**/ocom/ui-staff-route-finance/src/**", + "**/ocom/ui-staff-route-root/src/**", + "**/ocom/ui-staff-route-tech-admin/src/**", + "**/ocom/ui-staff-route-user-management/src/**", + "**/ocom/ui-staff-shared/src/**" + ], + "exclude": ["**/node_modules/**", "**/*.generated.*", "**/generated.*", "**/*.d.ts", "**/*.stories.*", "**/*.test.*", "**/*.spec.*"], "reporter": ["text", "lcovonly"], "report-dir": "coverage" } diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index 77ba7135b..d9603bcf7 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -5,15 +5,23 @@ "private": true, "type": "module", "scripts": { - "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js", - "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 cucumber-js" + "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js", + "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 cucumber-js", + "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 cucumber-js" }, "dependencies": { + "@apollo/client": "^3.13.9", "@cucumber/cucumber": "catalog:", + "@dr.pogodin/react-helmet": "^3.0.4", + "@serenity-js/console-reporter": "catalog:", "@serenity-js/core": "catalog:", "@serenity-js/cucumber": "catalog:", - "@serenity-js/console-reporter": "catalog:", "@serenity-js/serenity-bdd": "catalog:", + "antd": "catalog:", + "graphql": "catalog:", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-oidc-context": "^3.3.0", "std-env": "^4.0.0" }, "devDependencies": { @@ -25,8 +33,6 @@ "@types/react-dom": "^19.1.6", "c8": "^10.1.3", "jsdom": "^26.1.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", "tsx": "^4.20.3", "typescript": "catalog:" } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts new file mode 100644 index 000000000..7b1b83168 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts @@ -0,0 +1,5 @@ +export interface HeaderUiNotes { + signinRedirectCalled: boolean; + consoleErrorCalled: boolean; + fallbackInvoked: boolean; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx new file mode 100644 index 000000000..4cffe5960 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/header-login.steps.tsx @@ -0,0 +1,112 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; +import React from 'react'; +import { AuthContext, type AuthContextProps } from 'react-oidc-context'; +import { SectionLayout as CommunitySectionLayout } from '../../../../../../ocom/ui-community-route-root/src/section-layout.tsx'; +import { SectionLayout as StaffSectionLayout } from '../../../../../../ocom/ui-staff-route-root/src/section-layout.tsx'; +import { mountComponent } from '../../../shared/support/ui/react-render.ts'; +import type { CellixUiWorld } from '../../../world.ts'; +import type { HeaderUiNotes } from '../abilities/header-types.ts'; +import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; + +type Site = 'community' | 'staff'; + +interface HeaderScenarioState { + actorName: string; + site: Site; + identityProviderUnreachable: boolean; + originalConsoleError?: typeof console.error; + signinRedirectCalled: boolean; + errorCalled: boolean; +} + +function getState(world: CellixUiWorld): HeaderScenarioState { + const state = (world as unknown as { __headerState?: HeaderScenarioState }).__headerState; + if (!state) { + throw new Error('Header scenario state has not been initialised — did the Given step run?'); + } + return state; +} + +function initState(world: CellixUiWorld, actorName: string, site: Site): HeaderScenarioState { + const state: HeaderScenarioState = { + actorName, + site, + identityProviderUnreachable: false, + signinRedirectCalled: false, + errorCalled: false, + }; + (world as unknown as { __headerState: HeaderScenarioState }).__headerState = state; + return state; +} + +Given('{word} visits the community site', async function (this: CellixUiWorld, actorName: string) { + const actor = actorCalled(actorName); + initState(this, actorName, 'community'); + await actor.attemptsTo(notes().set('signinRedirectCalled', false), notes().set('consoleErrorCalled', false), notes().set('fallbackInvoked', false)); +}); + +Given('{word} visits the staff site', async function (this: CellixUiWorld, actorName: string) { + const actor = actorCalled(actorName); + initState(this, actorName, 'staff'); + await actor.attemptsTo(notes().set('signinRedirectCalled', false), notes().set('consoleErrorCalled', false), notes().set('fallbackInvoked', false)); +}); + +Given('the identity provider is unreachable', function (this: CellixUiWorld) { + const state = getState(this); + state.identityProviderUnreachable = true; +}); + +When('{word} chooses to sign in', async function (this: CellixUiWorld, _actorName: string) { + const state = getState(this); + + const signinRedirect = (): Promise => { + state.signinRedirectCalled = true; + if (state.identityProviderUnreachable) { + return Promise.reject(new Error('Simulated identity provider failure')); + } + return Promise.resolve(); + }; + + const authValue = { signinRedirect } as unknown as AuthContextProps; + const PageComponent = state.site === 'community' ? CommunitySectionLayout : StaffSectionLayout; + const wrapped = React.createElement(AuthContext.Provider, { value: authValue }, React.createElement(PageComponent)); + + state.originalConsoleError = console.error; + console.error = (..._args: unknown[]) => { + state.errorCalled = true; + }; + + const rendered = mountComponent(wrapped); + this.setHeaderContainer(rendered.container); + + try { + await ClickHeaderSignIn(rendered.container).performAs(actorCalled(state.actorName)); + } finally { + if (state.originalConsoleError) { + console.error = state.originalConsoleError; + } + const actor = actorCalled(state.actorName); + await actor.attemptsTo( + notes().set('signinRedirectCalled', state.signinRedirectCalled), + notes().set('consoleErrorCalled', state.errorCalled), + notes().set('fallbackInvoked', state.errorCalled), + ); + } +}); + +Then('{word} is taken to the sign-in flow', async function (this: CellixUiWorld, actorName: string) { + const actor = actorCalled(actorName); + const called = await actor.answer(notes().get('signinRedirectCalled')); + if (!called) { + throw new Error(`Expected ${actorName} to be taken to the sign-in flow, but the sign-in handler was not invoked`); + } +}); + +Then('{word} can still reach the sign-in page', async function (this: CellixUiWorld, actorName: string) { + const actor = actorCalled(actorName); + const fallback = await actor.answer(notes().get('fallbackInvoked')); + if (!fallback) { + throw new Error(`Expected ${actorName} to reach the sign-in page via the fallback path, but the fallback was not triggered`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/index.ts new file mode 100644 index 000000000..9a9868920 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Authentication context step definitions +import './header-login.steps.tsx'; 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 new file mode 100644 index 000000000..efb28f227 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -0,0 +1,20 @@ +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'; + +async function flushAsync(): Promise { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } +} + +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); + + await page.clickSignIn(); + await flushAsync(); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts index d38e04c21..c8f9fdf09 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts @@ -1,6 +1,5 @@ export interface CommunityUiNotes { communityName: string; - container: HTMLElement; formSubmitted: boolean; lastValidationError: string; } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.ts deleted file mode 100644 index 3495a9d64..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { actors } from '@ocom-verification/verification-shared/test-data'; -import { actorCalled, notes } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; -import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; -import { CommunityErrorMessage } from '../questions/community-error-message.ts'; -import { CommunityName } from '../questions/community-name.ts'; -import { CreateCommunity } from '../tasks/create-community.ts'; - -// Track last actor used in When steps so Then steps can reference them -let lastActorName = actors.CommunityOwner.name; - -Given('{word} is an authenticated community owner', async (actorName: string) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - - // Set up a minimal form container in jsdom for the test. - const container = document.getElementById('root') ?? document.createElement('div'); - container.innerHTML = ` -
- - - -
- `; - if (!container.parentElement) { - document.body.appendChild(container); - } - - const form = container.querySelector('form'); - const nameInput = container.querySelector('#community-name'); - - if (!form || !nameInput) { - throw new Error('Community form test fixture did not initialize correctly'); - } - - const syncValidity = () => { - nameInput.setCustomValidity(nameInput.value.trim().length === 0 ? 'Community name cannot be empty' : ''); - }; - - nameInput.addEventListener('input', () => { - syncValidity(); - container.dataset['formSubmitted'] = 'false'; - container.dataset['communityName'] = ''; - container.dataset['lastValidationError'] = ''; - }); - - nameInput.addEventListener('invalid', () => { - container.dataset['formSubmitted'] = 'false'; - container.dataset['communityName'] = ''; - container.dataset['lastValidationError'] = nameInput.validationMessage || 'Community name cannot be empty'; - }); - - form.addEventListener('submit', (event: SubmitEvent) => { - event.preventDefault(); - - const name = nameInput.value; - if (!name || name.trim().length === 0) { - container.dataset['formSubmitted'] = 'false'; - container.dataset['communityName'] = ''; - container.dataset['lastValidationError'] = 'Community name cannot be empty'; - return; - } - - container.dataset['formSubmitted'] = 'true'; - container.dataset['communityName'] = name; - container.dataset['lastValidationError'] = ''; - }); - - syncValidity(); - container.dataset['formSubmitted'] = 'false'; - container.dataset['communityName'] = ''; - container.dataset['lastValidationError'] = ''; - - await actor.attemptsTo(notes().set('container', container)); - await actor.attemptsTo(notes().set('formSubmitted', false)); - await actor.attemptsTo(notes().set('communityName', '')); - await actor.attemptsTo(notes().set('lastValidationError', '')); -}); - -When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); - const name = details['name'] ?? ''; - - await actor.attemptsTo(CreateCommunity(name)); -}); - -When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); - const name = details['name'] ?? ''; - - await actor.attemptsTo(CreateCommunity(name)); -}); - -Then('the community should be created successfully', async () => { - const actor = actorCalled(lastActorName); - const submitted = await actor.answer(CommunityCreatedFlag()); - - if (!submitted) { - throw new Error('Expected community form to be submitted'); - } -}); - -Then('the community name should be {string}', async (expectedName: string) => { - const actor = actorCalled(lastActorName); - const name = await actor.answer(CommunityName()); - - if (name !== expectedName) { - throw new Error(`Expected community name "${expectedName}" but got "${name}"`); - } -}); - -Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; - const actor = actorCalled(resolvedName); - - let storedError: string | undefined; - try { - storedError = await actor.answer(CommunityErrorMessage()); - } catch { - // No error - } - - if (storedError) { - const lowerError = storedError.toLowerCase(); - const lowerField = fieldName.toLowerCase(); - const isFieldMentioned = lowerError.includes(lowerField); - const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty/i.test(storedError); - - if (!isFieldMentioned && !isValidationPattern) { - throw new Error(`Expected a validation error related to "${fieldName}", but got: "${storedError}"`); - } - return; - } - - throw new Error(`Expected a validation error for "${fieldName}" but none was found`); -}); - -Then('no community should be created', async () => { - const actor = actorCalled(lastActorName); - - let hasValidationError = false; - try { - const storedError = await actor.answer(CommunityErrorMessage()); - hasValidationError = !!storedError; - } catch { - // No error stored - } - - if (!hasValidationError) { - throw new Error('Expected a validation error to prevent community creation, but no error was captured.'); - } -}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx new file mode 100644 index 000000000..ea8a143d9 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx @@ -0,0 +1,121 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; +import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; +import { actorCalled, notes } from '@serenity-js/core'; +import { CommunityCreate } from '../../../../../../ocom/ui-community-route-accounts/src/components/community-create.tsx'; +import { mountComponent } from '../../../shared/support/ui/react-render.ts'; +import type { CellixUiWorld } from '../../../world.ts'; +import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; +import { CommunityErrorMessage } from '../questions/community-error-message.ts'; +import { CommunityName } from '../questions/community-name.ts'; +import { CreateCommunity } from '../tasks/create-community.ts'; + +Given('{word} is an authenticated community owner', async function (this: CellixUiWorld, actorName: string) { + this.setCommunityActorName(actorName); + const actor = actorCalled(actorName); + + const onSave = async (values: { name: string }): Promise => { + await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? ''), notes().set('lastValidationError', '')); + }; + + const rendered = mountComponent(); + this.setCommunityContainer(rendered.container); + + await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), notes().set('lastValidationError', '')); +}); + +When('{word} creates a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { + this.setCommunityActorName(actorName); + const actor = actorCalled(actorName); + const { name: communityName = '' } = dataTable.rowsHash() as { name?: string }; + + await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); +}); + +When('{word} attempts to create a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { + this.setCommunityActorName(actorName); + const actor = actorCalled(actorName); + const { name: communityName = '' } = dataTable.rowsHash() as { name?: string }; + + await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); +}); + +Then('the community should be created successfully', async function (this: CellixUiWorld) { + const actor = actorCalled(this.getCommunityActorName()); + const submitted = await actor.answer(CommunityCreatedFlag()); + + if (!submitted) { + throw new Error('Expected community form to be submitted'); + } +}); + +Then('the community name should be {string}', async function (this: CellixUiWorld, expectedName: string) { + const actor = actorCalled(this.getCommunityActorName()); + const name = await actor.answer(CommunityName()); + + if (name !== expectedName) { + throw new Error(`Expected community name "${expectedName}" but got "${name}"`); + } +}); + +Then('{word} should see a community error for {string}', async function (this: CellixUiWorld, actorName: string, fieldName: string) { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? this.getCommunityActorName() : actorName; + + const container = this.getCommunityContainer(); + const adapter = new JsdomPageAdapter(container); + const page = new CommunityPage(adapter) as UiCommunityPage; + + let storedError: string | undefined; + try { + const errorEl = await page.firstValidationError; + if (errorEl) { + storedError = (await errorEl.textContent()) ?? undefined; + } + } catch { + const actor = actorCalled(resolvedName); + try { + storedError = await actor.answer(CommunityErrorMessage()); + } catch { + // No error found + } + } + + if (storedError) { + const lowerError = storedError.toLowerCase(); + const lowerField = fieldName.toLowerCase(); + const isFieldMentioned = lowerError.includes(lowerField); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|please input/i.test(storedError); + + if (!isFieldMentioned && !isValidationPattern) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${storedError}"`); + } + return; + } + + const errorElements = container.querySelectorAll('.ant-form-item-explain-error'); + if (errorElements.length > 0) { + return; + } + + throw new Error(`Expected a validation error for "${fieldName}" but none was found`); +}); + +Then('no community should be created', async function (this: CellixUiWorld) { + let hasValidationError = false; + try { + const actor = actorCalled(this.getCommunityActorName()); + const storedError = await actor.answer(CommunityErrorMessage()); + hasValidationError = !!storedError; + } catch { + // No error stored — check DOM + } + + if (!hasValidationError) { + const container = this.getCommunityContainer(); + const errorElements = container.querySelectorAll('.ant-form-item-explain-error'); + if (errorElements.length === 0) { + throw new Error('Expected a validation error to prevent community creation, but no error was found.'); + } + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index c04c54d61..bc657d814 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,2 @@ // Community context step definitions -import './create-community.steps.ts'; +import './create-community.steps.tsx'; 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 abaafabf4..2c2683b3e 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,24 +1,23 @@ import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { type Actor, Interaction, notes } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import { Interaction } from '@serenity-js/core'; -export const CreateCommunity = (name: string) => - Interaction.where(`#actor submits community form with name "${name}"`, async (actor) => { - const container: HTMLElement = await actor.answer(notes().get('container')); +async function flushAsync(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +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); await page.fillName(name); await page.clickCreate(); - const submitted = container.dataset['formSubmitted'] === 'true'; - const communityName = container.dataset['communityName'] ?? ''; - const lastValidationError = container.dataset['lastValidationError'] ?? ''; - - await (actor as Actor).attemptsTo( - notes().set('formSubmitted', submitted), - notes().set('communityName', communityName), - notes().set('lastValidationError', lastValidationError), - ); + await flushAsync(); }); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts index 394d62a90..212d84a85 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts @@ -1,11 +1,15 @@ -import { After, Before } from '@cucumber/cucumber'; +import { After, Before, setDefaultTimeout } from '@cucumber/cucumber'; +import { getTimeout } from '@ocom-verification/verification-shared/settings'; import type { CellixUiWorld } from '../../world.ts'; -import { unmountComponent } from './ui/react-render.tsx'; +import { unmountComponent } from './ui/react-render.ts'; -Before({ timeout: 30_000 }, async function (this: CellixUiWorld) { +/** Default scenario timeout from centralized configuration */ +setDefaultTimeout(getTimeout('scenario')); + +Before({ timeout: getTimeout('uiInit') }, async function (this: CellixUiWorld) { await this.init(); }); -After({ timeout: 10_000 }, async () => { - await unmountComponent(); +After({ timeout: getTimeout('uiCleanup') }, () => { + unmountComponent(); }); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts index 349b3edee..6b7116689 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts @@ -11,7 +11,8 @@ const dom = new JSDOM('
', { url: 'http://localhost:3000', pretendToBeVisual: true, }); -const domGlobal = (dom as unknown as Record)['window']; +// biome-ignore lint/complexity/useLiteralKeys: `dom.window` is exposed via JSDOM's index signature, requiring bracket access under strict TypeScript +const domGlobal = dom['window'] as unknown as Window & typeof globalThis; // biome-ignore lint/suspicious/noExplicitAny: attaching browser globals requires dynamic property assignment const g = globalThis as any; @@ -43,6 +44,8 @@ safeAssign('HTMLButtonElement', domGlobal.HTMLButtonElement); safeAssign('HTMLSelectElement', domGlobal.HTMLSelectElement); safeAssign('HTMLAnchorElement', domGlobal.HTMLAnchorElement); safeAssign('Element', domGlobal.Element); +safeAssign('SVGElement', domGlobal.SVGElement); +safeAssign('ShadowRoot', domGlobal.ShadowRoot ?? class ShadowRoot {}); safeAssign('Node', domGlobal.Node); safeAssign('NodeList', domGlobal.NodeList); safeAssign('Event', domGlobal.Event); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts new file mode 100644 index 000000000..d48b7eee8 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts @@ -0,0 +1,31 @@ +import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import { type RenderResult, render } from '@testing-library/react'; +import { App, ConfigProvider } from 'antd'; +import React from 'react'; + +let rendered: RenderResult | null = null; + +export interface MountOptions { + mocks?: MockedResponse[]; +} + +export function mountComponent(ui: React.ReactElement, options?: MountOptions): RenderResult { + unmountComponent(); + + const wrapped = React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, ui)))); + + rendered = render(wrapped); + return rendered; +} + +export function unmountComponent(): void { + if (rendered) { + rendered.unmount(); + rendered = null; + } +} + +export function getRendered(): RenderResult | null { + return rendered; +} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.tsx b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.tsx deleted file mode 100644 index 748741500..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { act } from '@testing-library/react'; - -export async function unmountComponent(): Promise { - const container = document.getElementById('root'); - if (!container) return; - - await act(() => { - container.innerHTML = ''; - delete container.dataset['formSubmitted']; - delete container.dataset['communityName']; - delete container.dataset['lastValidationError']; - }); -} diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 12f6b86ae..2107af436 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -6,3 +6,4 @@ import '../shared/support/ui/setup-jsdom.ts'; import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/world.ts b/packages/ocom-verification/acceptance-ui/src/world.ts index 331774a96..79e0fd932 100644 --- a/packages/ocom-verification/acceptance-ui/src/world.ts +++ b/packages/ocom-verification/acceptance-ui/src/world.ts @@ -4,12 +4,45 @@ import { CellixUiCast } from './shared/support/cast.ts'; export class CellixUiWorld extends World { private cast!: Cast; + private communityContainer: HTMLElement | null = null; + private communityActorName = ''; + private headerContainer: HTMLElement | null = null; init(): Promise { this.cast = new CellixUiCast(); serenity.engage(this.cast); return Promise.resolve(); } + + setCommunityContainer(container: HTMLElement): void { + this.communityContainer = container; + } + + getCommunityContainer(): HTMLElement { + if (!this.communityContainer) { + throw new Error('No community container available — did the Given step run?'); + } + return this.communityContainer; + } + + setCommunityActorName(actorName: string): void { + this.communityActorName = actorName; + } + + getCommunityActorName(): string { + return this.communityActorName; + } + + setHeaderContainer(container: HTMLElement): void { + this.headerContainer = container; + } + + getHeaderContainer(): HTMLElement { + if (!this.headerContainer) { + throw new Error('No header container available — did the Given step run?'); + } + return this.headerContainer; + } } setWorldConstructor(CellixUiWorld); diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 46010ede2..026eb254a 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -5,9 +5,13 @@ "erasableSyntaxOnly": false, "composite": false, "incremental": false, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noEmit": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], - "rootDir": "./src", + "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] } diff --git a/packages/ocom-verification/acceptance-ui/turbo.json b/packages/ocom-verification/acceptance-ui/turbo.json index 2aee17196..ce064baca 100644 --- a/packages/ocom-verification/acceptance-ui/turbo.json +++ b/packages/ocom-verification/acceptance-ui/turbo.json @@ -1,6 +1,12 @@ { "extends": ["//"], "tasks": { + "test:coverage:acceptance": { + "dependsOn": ["^build"], + "inputs": ["src/**/*.ts", "src/**/*.tsx", "cucumber.js", "package.json", ".c8rc.json"], + "outputs": ["coverage/**"], + "cache": false + }, "test:acceptance": { "dependsOn": ["^build"], "inputs": ["src/**/*.ts", "src/**/*.tsx", "cucumber.js", "package.json"], diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index 339a66b11..51912f713 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -7,5 +7,5 @@ export default { formatOptions: { snippetInterface: 'async-await', }, - parallel: 1, + parallel: 0, }; diff --git a/packages/ocom-verification/e2e-tests/package.json b/packages/ocom-verification/e2e-tests/package.json index cbe99de57..d0653152b 100644 --- a/packages/ocom-verification/e2e-tests/package.json +++ b/packages/ocom-verification/e2e-tests/package.json @@ -5,7 +5,11 @@ "private": true, "type": "module", "scripts": { - "test:e2e": "NODE_EXTRA_CA_CERTS=${HOME}/.portless/ca.pem LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js", + "test:e2e": "pnpm run proxy:start && pnpm run test:e2e:run", + "test:e2e:ci": "pnpm run proxy:start:ci && pnpm run test:e2e:run", + "test:e2e:run": "NODE_EXTRA_CA_CERTS=${HOME}/.portless/ca.pem LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js", + "proxy:start": "pnpm exec portless proxy start -p 1355", + "proxy:start:ci": "pnpm exec portless proxy start -p 1355 --skip-trust", "playwright:install": "playwright install chromium", "clean": "rimraf dist reports target" }, 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 new file mode 100644 index 000000000..1fbf8ad68 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -0,0 +1,149 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { BrowserContext, Page } from 'playwright'; +import * as infra from '../../../shared/support/shared-infrastructure.ts'; + +interface HeaderE2ENotes { + signinRedirectInvoked: boolean; + fallbackTriggered: boolean; + postLoginUrl: string; +} + +type Site = 'community' | 'staff'; + +interface HeaderE2EState { + actorName: string; + site: Site; + identityProviderUnreachable: boolean; + context?: BrowserContext; + page?: Page; +} + +let state: HeaderE2EState | undefined; + +function getHeaderState(): HeaderE2EState { + if (!state) throw new Error('Header scenario state not initialised'); + return state; +} + +/** Dispose the scenario's isolated browser context */ +async function cleanupHeaderContext(): Promise { + if (state?.page) { + await state.page.close().catch(() => undefined); + delete state.page; + } + if (state?.context) { + await state.context.close().catch(() => undefined); + delete state.context; + } +} + +Given('{word} visits the community site', async (actorName: string) => { + state = { actorName, site: 'community', identityProviderUnreachable: false }; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('signinRedirectInvoked', false), notes().set('fallbackTriggered', false), notes().set('postLoginUrl', '')); +}); + +Given('{word} visits the staff site', async (actorName: string) => { + state = { actorName, site: 'staff', identityProviderUnreachable: false }; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('signinRedirectInvoked', false), notes().set('fallbackTriggered', false), notes().set('postLoginUrl', '')); +}); + +Given('the identity provider is unreachable', () => { + getHeaderState().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 (actorName: string) => { + const s = getHeaderState(); + s.actorName = actorName; + + const { browser } = infra.getState(); + if (!browser) throw new Error('Browser not launched'); + + const baseUrl = s.site === 'community' ? (infra.getState().communityBaseUrl ?? 'https://ownercommunity.localhost:1355') : (infra.getState().staffBaseUrl ?? 'https://staff.ownercommunity.localhost:1355'); + + // Fresh unauthenticated context — isolated from the pre-auth context + // used by other test suites. Cleaned up in the Then step after verification. + const context = await browser.newContext({ + baseURL: baseUrl, + ignoreHTTPSErrors: true, + }); + s.context = context; + + if (s.identityProviderUnreachable) { + await context.route('**/mock-auth.**', (route) => route.abort('connectionrefused')); + } + + 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'); + } +}); + +Then('{word} is taken to the sign-in flow', async (actorName: string) => { + const { page } = getHeaderState(); + if (!page) throw new Error('No page — did the When step run?'); + + try { + // Verify the page actually landed back on the portal (not stuck on mock-auth) + const currentUrl = new URL(page.url()); + if (currentUrl.hostname.includes('mock-auth')) { + throw new Error(`Expected ${actorName} to complete the sign-in flow, but the page is still on the IdP: ${page.url()}`); + } + if (currentUrl.pathname.includes('auth-redirect')) { + throw new Error(`Expected ${actorName} to complete the sign-in flow, but the page is stuck on the auth redirect callback: ${page.url()}`); + } + } finally { + await cleanupHeaderContext(); + } +}); + +Then('{word} can still reach the sign-in page', async (actorName: string) => { + const { page } = getHeaderState(); + if (!page) throw new Error('No page — did the When step run?'); + + try { + // With the IdP unreachable, the header's fallback should have fired + // (direct navigation to the redirect URI). The page should NOT be on + // mock-auth (which was blocked). + const currentUrl = new URL(page.url()); + if (currentUrl.hostname.includes('mock-auth')) { + throw new Error(`Expected ${actorName} to reach the sign-in page via fallback, but somehow ended up on mock-auth: ${page.url()}`); + } + } finally { + await cleanupHeaderContext(); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/index.ts new file mode 100644 index 000000000..c137e8181 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Authentication context step definitions +import './header-login.steps.ts'; 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 fd4ad2fe8..f24fb78f4 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,9 +1,64 @@ 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 type { Response } from 'playwright'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +const createCommunityOperationName = 'AccountsCommunityCreateContainerCommunityCreate'; +const communityListOperationName = 'AccountsCommunityListContainerCommunitiesForCurrentEndUser'; +const memberListOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; + +type CommunityCreateGraphqlPayload = { + data?: { + communityCreate?: { + status?: { + success?: boolean; + errorMessage?: string | null; + }; + community?: { + name?: string | null; + } | null; + }; + }; + errors?: Array<{ message?: string }>; +}; + +type CommunityListGraphqlPayload = { + data?: { + communitiesForCurrentEndUser?: Array<{ name?: string | null }> | null; + membersForCurrentEndUser?: unknown[] | null; + }; + errors?: Array<{ message?: string }>; +}; + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => { + if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { + return false; + } + + return response.request().postData()?.includes(operationName) ?? false; +}; + +const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; '); + /** * Creates a community through the browser UI. */ @@ -11,34 +66,80 @@ 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', { + await page.goto('/community/accounts/create-community', { waitUntil: 'networkidle', }); const adapter = new PlaywrightPageAdapter(page); const communityPage: E2ECommunityPage = new CommunityPage(adapter); - await communityPage.createCommunityButton.click(); 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(); - try { - // Wait briefly for validation error to appear (if any) - await communityPage.firstValidationError.waitFor({ state: 'visible', timeout: 500 }).catch(() => { - // No error element appeared, form succeeded - }); - - const isError = await communityPage.firstValidationError.isVisible().catch(() => false); - if (isError) { - const errorText = await communityPage.firstValidationError.textContent(); - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Validation error')); - return; - } - - // No error found, form submitted successfully - await actor.attemptsTo(notes().set('communityName', name), notes().set('communityCreated', true), notes().set('errorMessage', null)); - } catch { - const errorText = await communityPage.errorToast.textContent(); - await actor.attemptsTo(notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Unknown error')); + 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)); }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts b/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts index 3f08366e1..477877fc7 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts @@ -3,12 +3,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ITestCaseHookParameter, IWorld } from '@cucumber/cucumber'; import { After, AfterAll, Before, Status, setDefaultTimeout } from '@cucumber/cucumber'; +import { getTimeout } from '@ocom-verification/verification-shared/settings'; import { type CellixE2EWorld, stopSharedServers } from '../../world.ts'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; const currentDir = fileURLToPath(new URL('.', import.meta.url)); -setDefaultTimeout(120_000); +/** Default scenario timeout from centralized configuration */ +setDefaultTimeout(getTimeout('scenario')); Before(async function (this: IWorld) { const world = this as IWorld & CellixE2EWorld; 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 b5db83c66..b7de3ea9a 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 @@ -29,6 +29,15 @@ 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. + 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"]'); + } + // Wait for the redirect chain to settle on an authenticated page await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); await page.waitForLoadState('networkidle'); @@ -56,5 +65,11 @@ export const OAuth2Login = (_email?: string, _password?: string) => // 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"]'); + } + await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); }); 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 6f2cca847..880273cce 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,6 +1,7 @@ export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; export { PortlessServer } from './portless-server.ts'; export { TestApiServer } from './test-api-server.ts'; -export { buildUrl, cleanupTestEnvironment, initTestEnvironment, setMongoConnectionString } from './test-environment.ts'; +export { TestCommunityViteServer } from './test-community-vite-server.ts'; +export { buildUrl, cleanupTestEnvironment, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer, setMongoConnectionString } from './test-environment.ts'; export { TestOAuth2Server } from './test-oauth2-server.ts'; -export { TestViteServer } from './test-vite-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 8b78c1c02..33d834460 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,37 +1,70 @@ import { type ChildProcess, spawn } from 'node:child_process'; -import { getPortlessPath } from './resolve-portless.ts'; +import type { TestServer } from '@ocom-verification/verification-shared/servers'; +import { getTimeout } from '@ocom-verification/verification-shared/settings'; /** - * Abstract base class for portless-proxied servers. - * Subclasses define the hostname, command, ready marker, and working directory. - * The base class handles spawning via portless, readiness detection, and shutdown. + * 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. */ -export abstract class PortlessServer { +export abstract class PortlessServer implements TestServer { private process: ChildProcess | null = null; private startedByUs = false; + private readonly useDetachedProcessGroup = process.platform !== 'win32'; protected abstract get probeUrl(): string; protected abstract get readyMarker(): string; protected abstract get serverName(): string; - protected abstract get startupTimeoutMs(): number; - protected abstract get spawnArgs(): string[]; protected abstract get cwd(): string; + protected abstract get spawnArgs(): string[]; + + protected get executable(): string { + return 'pnpm'; + } + + protected get probeRequestInit(): RequestInit { + return {}; + } + protected get extraEnv(): Record { return {}; } + protected isProbeHealthy(response: Response): boolean | Promise { + return response.ok; + } + + /** + * Check if server is already running (via health probe). + * Uses centralized health probe timeout. + */ async isAlreadyRunning(): Promise { try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3_000); - const res = await fetch(this.probeUrl, { signal: controller.signal }); + const probeTimeout = getTimeout('healthProbe'); + const timeout = setTimeout(() => controller.abort(), probeTimeout); + const res = await fetch(this.probeUrl, { ...this.probeRequestInit, signal: controller.signal }); clearTimeout(timeout); - return res.ok; + return await this.isProbeHealthy(res); } catch { return false; } } + /** + * 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; @@ -43,9 +76,10 @@ export abstract class PortlessServer { // Remove NODE_OPTIONS from child process to avoid tsx import issues delete env['NODE_OPTIONS']; - this.process = spawn(getPortlessPath(), this.spawnArgs, { + this.process = spawn(this.executable, this.spawnArgs, { cwd: this.cwd, env, + detached: this.useDetachedProcessGroup, stdio: ['ignore', 'pipe', 'pipe'], }); this.startedByUs = true; @@ -53,6 +87,10 @@ export abstract class PortlessServer { 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; @@ -60,13 +98,19 @@ export abstract class PortlessServer { this.process = null; this.startedByUs = false; - proc.kill('SIGTERM'); + // 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'); await new Promise((resolve) => { const timeout = setTimeout(() => { - proc.kill('SIGKILL'); + this.killProcess(proc, 'SIGKILL'); resolve(); - }, 10_000); + }, shutdownTimeout); proc.on('exit', () => { clearTimeout(timeout); @@ -75,10 +119,28 @@ export abstract class PortlessServer { }); } + /** + * 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; @@ -87,16 +149,37 @@ export abstract class PortlessServer { return; } + const startupTimeout = this.startupTimeoutMs; const timeout = setTimeout(() => { - reject(new Error(`${this.serverName} did not start within ${this.startupTimeoutMs}ms`)); - }, this.startupTimeoutMs); + reject(new Error(`${this.serverName} did not start within ${startupTimeout}ms`)); + }, startupTimeout); let stderrOutput = ''; + let ready = false; + const resolveWhenReachable = () => { + if (ready) { + return; + } + ready = true; + + this.waitForProbeReady() + .then(() => { + clearTimeout(timeout); + resolve(); + }) + .catch((error: unknown) => { + clearTimeout(timeout); + reject(error); + }); + }; + + // stdout/stderr listeners detect the readyMarker and collect stderr + // for error reporting if the process exits unexpectedly. proc.stdout?.on('data', (data: Buffer) => { - if (data.toString().includes(this.readyMarker)) { - clearTimeout(timeout); - resolve(); + const text = data.toString(); + if (text.includes(this.readyMarker)) { + resolveWhenReachable(); } }); @@ -111,12 +194,32 @@ export abstract class PortlessServer { reject(new Error(`${this.serverName} failed to start: ${err.message}`)); }); - proc.on('exit', (code) => { + proc.on('exit', (code, signal) => { clearTimeout(timeout); this.process = null; this.startedByUs = false; - reject(new Error(`${this.serverName} exited unexpectedly (code: ${code}). stderr: ${stderrOutput.slice(-2000)}`)); + reject(new Error(`${this.serverName} exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); }); }); } + + private async waitForProbeReady(): Promise { + const probeInterval = getTimeout('healthProbeInterval'); + while (!(await this.isAlreadyRunning())) { + await new Promise((resolve) => setTimeout(resolve, probeInterval)); + } + } + + private killProcess(proc: ChildProcess, signal: NodeJS.Signals): void { + if (this.useDetachedProcessGroup && proc.pid) { + try { + process.kill(-proc.pid, signal); + return; + } catch { + /* Fall back to killing the direct child below. */ + } + } + + proc.kill(signal); + } } 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 84a87174a..f3fcdaeac 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,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { apiSettings } from '@ocom-verification/verification-shared/settings'; import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getMongoConnectionString } from './test-environment.ts'; +import { buildUrl, getMongoConnectionString, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; export class TestApiServer extends PortlessServer { override async start(): Promise { @@ -24,17 +24,34 @@ export class TestApiServer extends PortlessServer { protected get probeUrl() { return buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); } + protected override get probeRequestInit(): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }; + } + protected override async isProbeHealthy(response: Response): Promise { + if (!response.ok) { + return false; + } + + const payload = (await response.json().catch(() => null)) as { + data?: { __typename?: string }; + errors?: unknown[]; + } | null; + + return payload?.data?.__typename === 'Query' && !payload.errors?.length; + } protected get readyMarker() { return 'Functions:'; } protected get serverName() { return 'TestApiServer'; } - protected get startupTimeoutMs() { - return 120_000; - } + protected get spawnArgs() { - return ['data-access.ownercommunity.localhost', 'node', 'start-dev.mjs']; + return ['run', 'dev']; } protected get cwd() { return apiSettings.apiDir; @@ -42,10 +59,28 @@ export class TestApiServer extends PortlessServer { 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: '', COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), - ACCOUNT_PORTAL_OIDC_ISSUER: apiSettings.accountPortalOidcIssuer, - ACCOUNT_PORTAL_OIDC_ENDPOINT: apiSettings.accountPortalOidcEndpoint, + 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'), }; } 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 new file mode 100644 index 000000000..3a75f64ef --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts @@ -0,0 +1,53 @@ +import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { PortlessServer } from './portless-server.ts'; +import { buildUrl, mockOidcIssuer } from './test-environment.ts'; + +/** + * 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. + */ +export class TestCommunityViteServer extends PortlessServer { + protected get probeUrl() { + return buildUrl('ownercommunity.localhost'); + } + protected get readyMarker() { + return 'ready in'; + } + protected get serverName() { + return 'TestCommunityViteServer'; + } + + /** + * Vite typically starts faster than API servers. + * Using a shorter timeout (60s vs default 120s) for faster feedback on failures. + */ + protected override get startupTimeoutMs() { + return 60_000; + } + + protected get spawnArgs() { + return ['run', 'dev']; + } + protected get cwd() { + return apiSettings.uiCommunityDir; + } + + protected override get extraEnv() { + const uiBase = buildUrl('ownercommunity.localhost'); + const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + + return { + 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'); + } +} 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 76c9a1d8d..771c04c53 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 @@ -7,8 +7,11 @@ let mongoConnectionString: string | undefined; export function initTestEnvironment() { if (proxyInitialized) return; - execFileSync(getPortlessPath(), ['proxy', 'start', '-p', '1355'], { - timeout: 15_000, + // 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', }); @@ -19,6 +22,19 @@ 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 function setMongoConnectionString(connStr: string): void { mongoConnectionString = connStr; } 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 319f80830..2ab755f70 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,81 +1,26 @@ import { apiSettings } from '@ocom-verification/verification-shared/settings'; import { PortlessServer } from './portless-server.ts'; -import { buildUrl } from './test-environment.ts'; +import { mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; export class TestOAuth2Server extends PortlessServer { protected get probeUrl() { - return apiSettings.accountPortalOidcEndpoint; + return mockOidcEndpoint; } protected get readyMarker() { - return 'Mock OAuth2 server running'; + return 'Registered OIDC config'; } protected get serverName() { return 'TestOAuth2Server'; } - protected get startupTimeoutMs() { - return 30_000; - } + protected get spawnArgs() { - return ['mock-auth.ownercommunity.localhost', 'pnpm', 'exec', 'tsx', 'src/index.ts']; + return ['run', 'dev']; } protected get cwd() { return apiSettings.oauth2MockDir; } - private readonly testUser: { - email: string; - given_name: string; - family_name: string; - }; - - constructor(options?: { - testUser?: { - email?: string; - given_name?: string; - family_name?: string; - }; - }) { - super(); - this.testUser = { - email: options?.testUser?.email ?? 'alice@test.cellix.local', - given_name: options?.testUser?.given_name ?? 'Alice', - family_name: options?.testUser?.family_name ?? 'Test', - }; - } - - protected override get extraEnv() { - return { - EMAIL: this.testUser.email, - GIVEN_NAME: this.testUser.given_name, - FAMILY_NAME: this.testUser.family_name, - BASE_URL: buildUrl('mock-auth.ownercommunity.localhost'), - ALLOWED_REDIRECT_URI: buildUrl('ownercommunity.localhost', '/auth-redirect'), - CLIENT_ID: apiSettings.accountPortalOidcAudience, - }; - } - getUrl(): string { - return apiSettings.accountPortalOidcIssuer; - } - - async generateAccessToken(_audience = 'mock-client'): Promise { - const issuer = this.getUrl(); - const uiBaseUrl = buildUrl('ownercommunity.localhost'); - const redirectUri = `${uiBaseUrl}/auth-redirect`; - - const code = `mock-auth-code-${Buffer.from(redirectUri).toString('base64')}`; - - const response = await fetch(`${issuer}/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, grant_type: 'authorization_code' }), - }); - - if (!response.ok) { - throw new Error(`Token request failed: ${response.status} ${await response.text()}`); - } - - const data = (await response.json()) as { access_token: string }; - return data.access_token; + return mockOidcIssuer; } } 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 new file mode 100644 index 000000000..28eb60f83 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -0,0 +1,49 @@ +import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { PortlessServer } from './portless-server.ts'; +import { buildUrl, mockStaffOidcIssuer } from './test-environment.ts'; + +/** + * Starts the staff portal Vite dev server as a subprocess via `pnpm run dev`. + */ +export class TestStaffViteServer extends PortlessServer { + protected get probeUrl() { + return buildUrl('staff.ownercommunity.localhost'); + } + protected get readyMarker() { + return 'ready in'; + } + protected get serverName() { + return 'TestStaffViteServer'; + } + + protected override get startupTimeoutMs() { + return 60_000; + } + + protected get spawnArgs() { + return ['run', 'dev']; + } + protected get cwd() { + return apiSettings.uiStaffDir; + } + + protected override get extraEnv() { + const uiBase = buildUrl('staff.ownercommunity.localhost'); + const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + + return { + 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'); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts deleted file mode 100644 index 44ede2444..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { apiSettings } from '@ocom-verification/verification-shared/settings'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl } from './test-environment.ts'; - -export class TestViteServer extends PortlessServer { - protected get probeUrl() { - return buildUrl('ownercommunity.localhost'); - } - protected get readyMarker() { - return 'ready in'; - } - protected get serverName() { - return 'TestViteServer'; - } - protected get startupTimeoutMs() { - return 60_000; - } - protected get spawnArgs() { - return ['ownercommunity.localhost', 'pnpm', 'exec', 'vite']; - } - protected get cwd() { - return apiSettings.uiDir; - } - - protected override get extraEnv() { - const uiBase = buildUrl('ownercommunity.localhost'); - const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); - - return { - BROWSER: 'none', - VITE_APP_UI_COMMUNITY_BASE_URL: uiBase, - VITE_AAD_B2C_ACCOUNT_AUTHORITY: apiSettings.accountPortalOidcIssuer, - VITE_AAD_B2C_REDIRECT_URI: `${uiBase}/auth-redirect`, - VITE_COMMON_API_ENDPOINT: apiEndpoint, - }; - } - - getUrl(): string { - return buildUrl('ownercommunity.localhost'); - } -} 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 d3d2c3ece..cb1a4e12e 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,29 +1,41 @@ -import { apiSettings } from '@ocom-verification/verification-shared/settings'; -import { actors } from '@ocom-verification/verification-shared/test-data'; 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, TestOAuth2Server, TestViteServer } from './servers/index.ts'; +import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestCommunityViteServer, TestOAuth2Server, TestStaffViteServer } from './servers/index.ts'; let mongoDBServer: MongoDBTestServer | undefined; let oauth2Server: TestOAuth2Server | undefined; let apiServer: TestApiServer | undefined; -let viteServer: TestViteServer | undefined; +let communityViteServer: TestCommunityViteServer | undefined; +let staffViteServer: TestStaffViteServer | undefined; let apiUrl: string | undefined; -let accessToken: string | undefined; let browser: Browser | undefined; let browserBaseUrl: string | undefined; let authenticatedBrowserContext: BrowserContext | undefined; let browseTheWeb: BrowseTheWeb | undefined; +let shutdownHandlersRegistered = false; export interface InfrastructureState { apiUrl: string | undefined; - accessToken: string | undefined; browseTheWeb: BrowseTheWeb | undefined; + staffBaseUrl: string | undefined; + communityBaseUrl: string | undefined; + browser: Browser | undefined; } export function getState(): InfrastructureState { - return { apiUrl, accessToken, browseTheWeb }; + return { apiUrl, browseTheWeb, staffBaseUrl: staffViteServer?.getUrl(), communityBaseUrl: browserBaseUrl, browser }; +} + +/** + * 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()) { + await mongoDBServer.resetForScenario(); + } } export async function stopAll(): Promise { @@ -38,9 +50,13 @@ export async function stopAll(): Promise { await browser.close().catch(() => undefined); browser = undefined; } - if (viteServer) { - await viteServer.stop().catch(() => undefined); - viteServer = undefined; + if (communityViteServer) { + await communityViteServer.stop().catch(() => undefined); + communityViteServer = undefined; + } + if (staffViteServer) { + await staffViteServer.stop().catch(() => undefined); + staffViteServer = undefined; } if (apiServer) { await apiServer.stop().catch(() => undefined); @@ -56,22 +72,17 @@ export async function stopAll(): Promise { } apiUrl = undefined; browserBaseUrl = undefined; - accessToken = undefined; cleanupTestEnvironment(); } export async function ensureE2EServers(): Promise { initTestEnvironment(); + registerShutdownHandlers(); + // Phase 1: Start MongoDB and OAuth2 in parallel (no interdependency) mongoDBServer ??= new MongoDBTestServer(); - oauth2Server ??= new TestOAuth2Server({ - testUser: { - email: actors.CommunityOwner.email, - given_name: actors.CommunityOwner.givenName, - family_name: actors.CommunityOwner.familyName, - }, - }); + oauth2Server ??= new TestOAuth2Server(); const mongo = mongoDBServer; const oauth2 = oauth2Server; const phase1: Promise[] = []; @@ -85,9 +96,11 @@ export async function ensureE2EServers(): Promise { // Phase 2: Start API (needs MongoDB conn string), Vite (independent), and generate token (needs OAuth2) in parallel apiServer ??= new TestApiServer(); - viteServer ??= new TestViteServer(); + communityViteServer ??= new TestCommunityViteServer(); + staffViteServer ??= new TestStaffViteServer(); const api = apiServer; - const vite = viteServer; + const vite = communityViteServer; + const staffVite = staffViteServer; const phase2: Promise[] = []; if (!api.isRunning()) { phase2.push( @@ -99,16 +112,12 @@ export async function ensureE2EServers(): Promise { if (!vite.isRunning()) { phase2.push(vite.start()); } - if (!accessToken) { - phase2.push( - oauth2.generateAccessToken(apiSettings.accountPortalOidcAudience).then((token) => { - accessToken = token; - }), - ); + if (!staffVite.isRunning()) { + phase2.push(staffVite.start()); } if (phase2.length > 0) await Promise.all(phase2); - browserBaseUrl = viteServer.getUrl(); + browserBaseUrl = communityViteServer.getUrl(); if (!apiUrl) { apiUrl = apiServer?.getUrl(); @@ -150,3 +159,17 @@ async function ensureAuthenticatedBrowserContext(options: { baseURL?: string; ig throw error; } } + +function registerShutdownHandlers(): void { + if (shutdownHandlersRegistered) return; + shutdownHandlersRegistered = true; + + const shutdown = (signal: string) => { + void stopAll().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + + process.once('SIGINT', () => shutdown('SIGINT')); + process.once('SIGTERM', () => shutdown('SIGTERM')); +} diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 8349e7969..fb2ff8a4f 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,3 +5,4 @@ import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/world.ts b/packages/ocom-verification/e2e-tests/src/world.ts index 2730b9dd1..e40e2eb21 100644 --- a/packages/ocom-verification/e2e-tests/src/world.ts +++ b/packages/ocom-verification/e2e-tests/src/world.ts @@ -21,7 +21,9 @@ export class CellixE2EWorld extends World { } async cleanup(): Promise { - // Reuse same browser session across scenarios. + // Reset DB state between scenarios so each starts from a clean baseline. + // Servers stay running — only mutable data is cleared and re-seeded. + await infra.resetScenarioState(); } } diff --git a/packages/ocom-verification/e2e-tests/turbo.json b/packages/ocom-verification/e2e-tests/turbo.json index 4a199c38c..87d650253 100644 --- a/packages/ocom-verification/e2e-tests/turbo.json +++ b/packages/ocom-verification/e2e-tests/turbo.json @@ -6,6 +6,11 @@ "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], "cache": false }, + "test:e2e:ci": { + "dependsOn": ["^build"], + "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], + "cache": false + }, "test:serenity": { "dependsOn": ["^build"], "inputs": ["src/**/*.ts", "cucumber.js", "package.json"], diff --git a/packages/ocom-verification/verification-shared/package.json b/packages/ocom-verification/verification-shared/package.json index 4f99fb36a..13c3822aa 100644 --- a/packages/ocom-verification/verification-shared/package.json +++ b/packages/ocom-verification/verification-shared/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@apollo/server": "catalog:", + "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", "@ocom/service-mongoose": "workspace:*", "@cucumber/cucumber": "catalog:", "@cucumber/messages": "catalog:", @@ -26,7 +27,6 @@ "graphql-depth-limit": "^1.1.0", "graphql-middleware": "^6.1.35", "mongodb": "catalog:", - "mongodb-memory-server": "^10.2.0", "mongoose": "catalog:" }, "devDependencies": { diff --git a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts b/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts index 5b23b2813..fb682eef0 100644 --- a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts +++ b/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import type { ElementHandle, PageAdapter, PageNavigationWaitUntil, PageUrlMatcher } from '../page-adapter.ts'; function getGlobalDocument(container: Element): Document { @@ -30,28 +30,46 @@ class JsdomElementHandle implements ElementHandle { constructor(private readonly el: Element | null) {} fill(value: string): Promise { - if (this.el) { - fireEvent.input(this.el, { target: { value } }); - fireEvent.change(this.el, { target: { value } }); - } + if (!this.el) return Promise.resolve(); + + const input = this.el as HTMLInputElement; + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, value); + } else { + input.value = value; + } + fireEvent.input(input, { target: { value } }); + fireEvent.change(input, { target: { value } }); + }); return Promise.resolve(); } click(): Promise { if (this.el) { - fireEvent.click(this.el); + const el = this.el; + act(() => { + fireEvent.click(el); + }); } return Promise.resolve(); } check(): Promise { if (this.el instanceof HTMLInputElement) { - fireEvent.click(this.el, { target: { checked: true } }); + const el = this.el; + act(() => { + fireEvent.click(el, { target: { checked: true } }); + }); return Promise.resolve(); } if (this.el) { - fireEvent.click(this.el); + const el = this.el; + act(() => { + fireEvent.click(el); + }); } return Promise.resolve(); } diff --git a/packages/ocom-verification/verification-shared/src/pages/home.page.ts b/packages/ocom-verification/verification-shared/src/pages/home.page.ts new file mode 100644 index 000000000..435596ca3 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/home.page.ts @@ -0,0 +1,18 @@ +import type { ElementHandle, PageAdapter } from './page-adapter.ts'; + +/** + * Home page object — represents the landing screen that contains the + * site header with sign-in controls. Works with both jsdom (acceptance + * UI tests) and Playwright (e2e tests) via the PageAdapter abstraction. + */ +export class HomePage { + constructor(private readonly adapter: PageAdapter) {} + + get signInButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Log In|Sign In/i }); + } + + async clickSignIn(): Promise { + await this.signInButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 74494ab66..8c8ff6683 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,4 +1,5 @@ export { CommunityPage } from './community.page.ts'; +export { HomePage } from './home.page.ts'; export { LoginPage } from './login.page.ts'; export type { ElementHandle, @@ -9,7 +10,9 @@ export type { } from './page-adapter.ts'; export type { E2ECommunityPage, + E2EHomePage, E2ELoginPage, UiCommunityPage, + UiHomePage, UiLoginPage, } from './page-interfaces/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts index 0a3cc952a..584459540 100644 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts +++ b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts @@ -1,5 +1,5 @@ import type { CommunityPage } from '../community.page.ts'; -export type UiCommunityPage = Pick; +export type UiCommunityPage = Pick; -export type E2ECommunityPage = Pick; +export type E2ECommunityPage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts new file mode 100644 index 000000000..e279f8c47 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts @@ -0,0 +1,5 @@ +import type { HomePage } from '../home.page.ts'; + +export type UiHomePage = Pick; + +export type E2EHomePage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts index fb0c2a804..9a095a538 100644 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts @@ -2,6 +2,10 @@ export type { E2ECommunityPage, UiCommunityPage, } from './community.page-interface.ts'; +export type { + E2EHomePage, + UiHomePage, +} from './home.page-interface.ts'; export type { E2ELoginPage, UiLoginPage, diff --git a/packages/ocom-verification/verification-shared/src/scenarios/authentication/header-login.feature b/packages/ocom-verification/verification-shared/src/scenarios/authentication/header-login.feature new file mode 100644 index 000000000..a0514e4af --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/authentication/header-login.feature @@ -0,0 +1,27 @@ +Feature: Sign In From Header + + As an unauthenticated visitor + I want to sign in from the site header + So that I can access my account + + Scenario: Visitor signs in to the community site + Given Alex visits the community site + When Alex chooses to sign in + Then Alex is taken to the sign-in flow + + Scenario: Visitor signs in to the staff site + Given Alex visits the staff site + When Alex chooses to sign in + Then Alex is taken to the sign-in flow + + Scenario: Community visitor can still reach sign-in when the identity provider is unreachable + Given Alex visits the community site + And the identity provider is unreachable + When Alex chooses to sign in + Then Alex can still reach the sign-in page + + Scenario: Staff visitor can still reach sign-in when the identity provider is unreachable + Given Alex visits the staff site + And the identity provider is unreachable + When Alex chooses to sign in + Then Alex can still reach the sign-in page diff --git a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts b/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts index 8de3f51e7..acae9ca37 100644 --- a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts +++ b/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts @@ -4,6 +4,8 @@ import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/appl import { combinedSchema } from '@ocom/graphql'; import depthLimit from 'graphql-depth-limit'; import { applyMiddleware } from 'graphql-middleware'; +import { getTimeout } from '../settings/index.ts'; +import type { TestServer } from './test-server.interface.ts'; interface GraphContext { applicationServices: ApplicationServices; @@ -11,14 +13,30 @@ interface GraphContext { const MAX_QUERY_DEPTH = 10; -// In-process Apollo Server for API acceptance and integration tests -export class GraphQLTestServer { +/** + * In-process Apollo Server for API acceptance and integration tests. + * + * This server runs the GraphQL schema directly in the test process, + * providing fast feedback with mocked application services. + * + * Use this for: + * - API acceptance tests + * - Unit-like integration tests + * - Fast feedback loops + * + * For full system tests, use PortlessServer-based implementations instead. + */ +export class GraphQLTestServer implements TestServer { private server: ApolloServer | null = null; private url: string | null = null; constructor(private readonly applicationServicesFactory?: ApplicationServicesFactory) {} - async start(port = 0): Promise { + /** + * Start the GraphQL server on the specified port (or random port if 0). + * Uses centralized timeout configuration. + */ + async start(port = 0): Promise { if (this.server) { throw new Error('Test server already started'); } @@ -32,6 +50,9 @@ export class GraphQLTestServer { introspection: false, }); + const timeoutMs = getTimeout('serverStartup'); + const startTime = Date.now(); + const { url } = await startStandaloneServer(this.server, { listen: { port }, context: async ({ req }) => { @@ -49,10 +70,17 @@ export class GraphQLTestServer { }, }); + const elapsed = Date.now() - startTime; + if (elapsed > timeoutMs * 0.8) { + console.warn(`GraphQLTestServer startup took ${elapsed}ms (timeout: ${timeoutMs}ms)`); + } + this.url = url; - return url; } + /** + * Stop the server gracefully. + */ async stop(): Promise { if (!this.server) { return; @@ -63,6 +91,10 @@ export class GraphQLTestServer { this.url = null; } + /** + * Get the server URL. + * @throws Error if server is not running + */ getUrl(): string { if (!this.url) { throw new Error('Test server not started'); @@ -70,6 +102,9 @@ export class GraphQLTestServer { return this.url; } + /** + * Check if server is currently running. + */ isRunning(): boolean { return this.server !== null; } diff --git a/packages/ocom-verification/verification-shared/src/servers/index.ts b/packages/ocom-verification/verification-shared/src/servers/index.ts index 585f779f2..32810914a 100644 --- a/packages/ocom-verification/verification-shared/src/servers/index.ts +++ b/packages/ocom-verification/verification-shared/src/servers/index.ts @@ -7,3 +7,4 @@ export { MongoDBTestServer, seedOwnerCommunityReferenceData, } from './test-mongodb-server.ts'; +export type { TestServer, TestServerOptions } 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 37937e616..53fa6fc1a 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,11 +1,10 @@ +import { type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; import { ServiceMongoose } from '@ocom/service-mongoose'; import { MongoClient, ObjectId } from 'mongodb'; -import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { apiSettings } from '../settings/index.ts'; import { getAllMockUsers } from '../test-data/index.ts'; -const MONGO_BINARY_VERSION = '7.0.14'; -const DEFAULT_DB_NAME = 'owner-community-test'; -const MAX_REPLSET_START_ATTEMPTS = 5; +const DEFAULT_REPL_SET_NAME = 'globaldb'; export type MongoDBSeedDataFunction = (connectionString: string, dbName: string) => Promise; @@ -52,28 +51,34 @@ export async function seedOwnerCommunityReferenceData(connectionString: string, } /** - * In-memory MongoDB replica set with a Mongoose service attached. - * Provides the test database for acceptance tests — callers supply - * an optional db name and seed function. + * 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. */ export class MongoDBTestServer { - private replSet: MongoMemoryReplSet | null = null; + private disposer: MongoMemoryReplicaSetDisposer | null = null; private serviceMongoose: ServiceMongoose | null = null; - private dbName = ''; + private connectionString = ''; + private dbName = apiSettings.cosmosDbName; + private startedByUs = false; async start(options?: MongoDBTestServerStartOptions): Promise { - this.dbName = options?.dbName ?? DEFAULT_DB_NAME; - - const config = { - binary: { version: MONGO_BINARY_VERSION }, - replSet: { name: 'rs0', count: 1, storageEngine: 'wiredTiger' as const }, - ...(options?.port && { instanceOpts: [{ port: options.port }] }), - }; - - this.replSet = await this.createReplicaSetWithRetry(config); - const uri = this.replSet.getUri(); + 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(uri, { + this.serviceMongoose = new ServiceMongoose(this.connectionString, { dbName: this.dbName, autoIndex: true, autoCreate: true, @@ -90,7 +95,7 @@ export class MongoDBTestServer { } const seedFn = options?.seedDataFn ?? seedOwnerCommunityReferenceData; - await seedFn(uri, this.dbName); + await seedFn(this.connectionString, this.dbName); } getServiceMongoose(): ServiceMongoose { @@ -101,10 +106,10 @@ export class MongoDBTestServer { } getConnectionString(): string { - if (!this.replSet) { + if (!this.connectionString) { throw new Error('MongoDBTestServer not started'); } - return this.replSet.getUri(); + return this.connectionString; } async stop(): Promise { @@ -112,39 +117,31 @@ export class MongoDBTestServer { await this.serviceMongoose.shutDown(); this.serviceMongoose = null; } - if (this.replSet) { - await this.replSet.stop(); - this.replSet = null; + if (this.disposer && this.startedByUs) { + const disposer = this.disposer; + this.disposer = null; + this.startedByUs = false; + await disposer.stop(); } } - isRunning(): boolean { - return this.serviceMongoose !== null; - } - - private async createReplicaSetWithRetry(config: Parameters[0]): Promise { - let lastError: unknown; - - for (let attempt = 1; attempt <= MAX_REPLSET_START_ATTEMPTS; attempt += 1) { - try { - return await MongoMemoryReplSet.create(config); - } catch (error) { - lastError = error; - if (!this.isPortInUseError(error) || attempt === MAX_REPLSET_START_ATTEMPTS || config.instanceOpts) { - throw error; - } - } + async resetForScenario(seedDataFn?: MongoDBSeedDataFunction): Promise { + if (!this.serviceMongoose) { + throw new Error('MongoDBTestServer not started'); } - - throw lastError instanceof Error ? lastError : new Error('Failed to start MongoDB replica set'); - } - - private isPortInUseError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; + 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); + } - return error.message.includes('already in use') || error.message.includes('EADDRINUSE'); + isRunning(): boolean { + return this.serviceMongoose !== null; } static async isReachable(connectionString: string): Promise { @@ -168,3 +165,12 @@ export class MongoDBTestServer { await seedOwnerCommunityReferenceData(connectionString, 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; +} 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 new file mode 100644 index 000000000..8b08f6b92 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts @@ -0,0 +1,40 @@ +/** + * 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 + */ +export interface TestServer { + /** Start the server and return when ready */ + start(): Promise; + + /** Stop the server gracefully */ + stop(): Promise; + + /** Check if server is currently running */ + isRunning(): boolean; + + /** 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 88ed046dd..4e28ad534 100644 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ b/packages/ocom-verification/verification-shared/src/settings/index.ts @@ -7,3 +7,4 @@ export { requireSetting, resolveWorkspacePath, } from './settings-utils.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 index e1a03e801..d075b6b25 100644 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts @@ -8,21 +8,41 @@ 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: readSetting(apiValues, 'NODE_ENV', 'development') ?? 'development', - isDevelopment: (readSetting(apiValues, 'NODE_ENV', 'development') ?? 'development') === 'development', + nodeEnv: setting('NODE_ENV'), + isDevelopment: setting('NODE_ENV') === 'development', - cosmosDbConnectionString: readSetting(apiValues, 'COSMOSDB_CONNECTION_STRING') ?? '', - cosmosDbName: readSetting(apiValues, 'COSMOSDB_DBNAME', 'owner-community') ?? 'owner-community', - cosmosDbPort: Number(readSetting(apiValues, 'COSMOSDB_PORT', '50000')), + cosmosDbConnectionString: setting('COSMOSDB_CONNECTION_STRING'), + cosmosDbName: setting('COSMOSDB_DBNAME'), + cosmosDbPort: Number(setting('COSMOSDB_PORT')), - accountPortalOidcIssuer: readSetting(apiValues, 'ACCOUNT_PORTAL_OIDC_ISSUER') ?? '', - accountPortalOidcEndpoint: readSetting(apiValues, 'ACCOUNT_PORTAL_OIDC_ENDPOINT') ?? '', - accountPortalOidcAudience: readSetting(apiValues, 'ACCOUNT_PORTAL_OIDC_AUDIENCE', 'mock-client') ?? '', + 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'), - uiDir: path.dirname(uiEnvPath), + uiCommunityDir: path.dirname(uiEnvPath), + uiStaffDir: path.join(workspaceRoot, 'apps', 'ui-staff'), } as const; export const uiSettings = { diff --git a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts new file mode 100644 index 000000000..309ef7f3e --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts @@ -0,0 +1,48 @@ +/** + * Centralized timeout configuration for all verification test packages. + * + * These timeouts are intentionally generous to accommodate: + * - CI environments with limited resources + * - First-time server startup (cold starts) + * - Parallel test execution contention + */ +export const timeouts = { + /** Default scenario timeout (2 minutes) */ + scenario: 120_000, + + /** Server startup timeout (2 minutes) */ + serverStartup: 120_000, + + /** Server shutdown graceful period (10 seconds) */ + serverShutdown: 10_000, + + /** Health probe timeout (3 seconds) */ + healthProbe: 3_000, + + /** Health probe retry interval (500ms) */ + healthProbeInterval: 500, + + /** UI initialization timeout (30 seconds) */ + uiInit: 30_000, + + /** UI cleanup timeout (10 seconds) */ + uiCleanup: 10_000, +} as const; + +/** Type for timeout configuration keys */ +export type TimeoutKey = keyof typeof timeouts; + +/** + * Get timeout value with optional override from environment. + * Usage: TIMEOUT_SERVER_STARTUP=300000 npm test + */ +export function getTimeout(key: TimeoutKey): number { + const envOverride = process.env[`TIMEOUT_${key.toUpperCase()}`]; + if (envOverride) { + const parsed = Number.parseInt(envOverride, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return timeouts[key]; +} diff --git a/packages/ocom/application-services/package.json b/packages/ocom/application-services/package.json index 9e5c5035a..aecefb017 100644 --- a/packages/ocom/application-services/package.json +++ b/packages/ocom/application-services/package.json @@ -19,7 +19,6 @@ "build": "tsgo --build", "watch": "tsgo --watch", "test": "vitest run", - "test:coverage": "vitest run --coverage", "test:watch": "vitest", "test:arch": "vitest run --config vitest.arch.config.ts", "lint": "biome lint", diff --git a/packages/ocom/domain/package.json b/packages/ocom/domain/package.json index 516fd60fd..cb830c672 100644 --- a/packages/ocom/domain/package.json +++ b/packages/ocom/domain/package.json @@ -19,7 +19,6 @@ "build": "tsgo --build", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:acceptance": "cucumber-js", "test:integration": "vitest run integration.test.ts --silent --reporter=dot", "test:serenity": "cucumber-js", diff --git a/packages/ocom/graphql/package.json b/packages/ocom/graphql/package.json index 5771f0293..a22c8a3ab 100644 --- a/packages/ocom/graphql/package.json +++ b/packages/ocom/graphql/package.json @@ -21,7 +21,6 @@ "build": "tsgo --build", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest", "test:arch": "vitest run --config vitest.arch.config.ts", "clean": "rimraf dist **/*.generated.ts **/graphql.schema.json" diff --git a/packages/ocom/persistence/package.json b/packages/ocom/persistence/package.json index 2f02445d2..1d88d3da1 100644 --- a/packages/ocom/persistence/package.json +++ b/packages/ocom/persistence/package.json @@ -115,7 +115,6 @@ "build": "tsgo --build", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest", "test:arch": "vitest run --config vitest.arch.config.ts", "lint": "biome lint", diff --git a/packages/ocom/ui-community-route-accounts/package.json b/packages/ocom/ui-community-route-accounts/package.json index 3566e168a..b54f01110 100644 --- a/packages/ocom/ui-community-route-accounts/package.json +++ b/packages/ocom/ui-community-route-accounts/package.json @@ -22,7 +22,7 @@ "@cellix/ui-core": "workspace:*", "@dr.pogodin/react-helmet": "^3.0.2", "@graphql-typed-document-node/core": "^3.2.0", - "@ocom/ui-community-shared": "workspace:*", + "@ocom/ui-community-shared": "workspace:*", "@ocom/ui-shared": "workspace:*", "antd": "catalog:", "react": "catalog:", diff --git a/packages/ocom/ui-community-route-accounts/src/vite-env.d.ts b/packages/ocom/ui-community-route-accounts/src/vite-env.d.ts index c7be645fb..94140be2f 100644 --- a/packages/ocom/ui-community-route-accounts/src/vite-env.d.ts +++ b/packages/ocom/ui-community-route-accounts/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-community-route-admin/src/vite-env.d.ts b/packages/ocom/ui-community-route-admin/src/vite-env.d.ts index c7be645fb..94140be2f 100644 --- a/packages/ocom/ui-community-route-admin/src/vite-env.d.ts +++ b/packages/ocom/ui-community-route-admin/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-community-route-root/package.json b/packages/ocom/ui-community-route-root/package.json index e4aac94d2..e24116fd5 100644 --- a/packages/ocom/ui-community-route-root/package.json +++ b/packages/ocom/ui-community-route-root/package.json @@ -16,7 +16,7 @@ "test:arch": "vitest run --config vitest.arch.config.ts" }, "dependencies": { - "@ocom/ui-community-shared": "workspace:*", + "@ocom/ui-community-shared": "workspace:*", "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/ocom/ui-community-route-root/src/components/header.tsx b/packages/ocom/ui-community-route-root/src/components/header.tsx index 65ccad217..9a2aaa931 100644 --- a/packages/ocom/ui-community-route-root/src/components/header.tsx +++ b/packages/ocom/ui-community-route-root/src/components/header.tsx @@ -11,12 +11,12 @@ export const Header: React.FC = () => { await auth.signinRedirect(); return; } - } catch (_err) { - // swallow and fall back below + } catch (err) { + console.error('OIDC signinRedirect failed, falling back to direct navigation', err); } // fall back to direct navigation if the OIDC helper is unavailable or fails - globalThis.location.href = `${import.meta.env.VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI}`; + globalThis.location.href = `${(import.meta as { env?: ImportMetaEnv }).env?.VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI ?? ''}`; }; const { diff --git a/packages/ocom/ui-community-shared/package.json b/packages/ocom/ui-community-shared/package.json index 7c80106f0..e4df59239 100644 --- a/packages/ocom/ui-community-shared/package.json +++ b/packages/ocom/ui-community-shared/package.json @@ -12,7 +12,6 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/ocom/ui-community-shared/src/index.tsx b/packages/ocom/ui-community-shared/src/index.tsx index 3d116ad87..2863ba63d 100644 --- a/packages/ocom/ui-community-shared/src/index.tsx +++ b/packages/ocom/ui-community-shared/src/index.tsx @@ -1,2 +1,2 @@ export { MemberProfileContainer, type MemberProfileContainerProps } from './components/member-profile.container.tsx'; -export { MenuComponent, type MenuComponentProps, type PageLayoutProps } from './components/menu-component.tsx'; \ No newline at end of file +export { MenuComponent, type MenuComponentProps, type PageLayoutProps } from './components/menu-component.tsx'; diff --git a/packages/ocom/ui-staff-route-community-management/package.json b/packages/ocom/ui-staff-route-community-management/package.json index 98bd912db..02a8dd4e9 100644 --- a/packages/ocom/ui-staff-route-community-management/package.json +++ b/packages/ocom/ui-staff-route-community-management/package.json @@ -13,7 +13,6 @@ "format:check": "biome format .", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/ocom/ui-staff-route-community-management/src/vite-env.d.ts b/packages/ocom/ui-staff-route-community-management/src/vite-env.d.ts index 33b90b189..2ec5dab8d 100644 --- a/packages/ocom/ui-staff-route-community-management/src/vite-env.d.ts +++ b/packages/ocom/ui-staff-route-community-management/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-staff-route-finance/package.json b/packages/ocom/ui-staff-route-finance/package.json index 121545add..16ac73f52 100644 --- a/packages/ocom/ui-staff-route-finance/package.json +++ b/packages/ocom/ui-staff-route-finance/package.json @@ -13,7 +13,6 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/ocom/ui-staff-route-finance/src/vite-env.d.ts b/packages/ocom/ui-staff-route-finance/src/vite-env.d.ts index 33b90b189..2ec5dab8d 100644 --- a/packages/ocom/ui-staff-route-finance/src/vite-env.d.ts +++ b/packages/ocom/ui-staff-route-finance/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-staff-route-root/package.json b/packages/ocom/ui-staff-route-root/package.json index dac6c57bd..73fc3c347 100644 --- a/packages/ocom/ui-staff-route-root/package.json +++ b/packages/ocom/ui-staff-route-root/package.json @@ -11,11 +11,10 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { - "@ocom/ui-staff-shared": "workspace:*", + "@ocom/ui-staff-shared": "workspace:*", "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/ocom/ui-staff-route-root/src/components/header.tsx b/packages/ocom/ui-staff-route-root/src/components/header.tsx index ad1cf626e..024e33481 100644 --- a/packages/ocom/ui-staff-route-root/src/components/header.tsx +++ b/packages/ocom/ui-staff-route-root/src/components/header.tsx @@ -13,12 +13,12 @@ export const Header: React.FC = () => { await auth.signinRedirect(); return; } - } catch (_err) { - // swallow and fall back below + } catch (err) { + console.error('OIDC signinRedirect failed, falling back to direct navigation', err); } // fall back to direct navigation if the OIDC helper is unavailable or fails - globalThis.location.href = `${import.meta.env.VITE_APP_UI_STAFF_AAD_REDIRECT_URI}`; + globalThis.location.href = `${(import.meta as { env?: ImportMetaEnv }).env?.VITE_APP_UI_STAFF_AAD_REDIRECT_URI ?? ''}`; }; const { diff --git a/packages/ocom/ui-staff-route-tech-admin/package.json b/packages/ocom/ui-staff-route-tech-admin/package.json index 59259a83c..a1622d8fb 100644 --- a/packages/ocom/ui-staff-route-tech-admin/package.json +++ b/packages/ocom/ui-staff-route-tech-admin/package.json @@ -11,7 +11,6 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/ocom/ui-staff-route-tech-admin/src/vite-env.d.ts b/packages/ocom/ui-staff-route-tech-admin/src/vite-env.d.ts index 33b90b189..2ec5dab8d 100644 --- a/packages/ocom/ui-staff-route-tech-admin/src/vite-env.d.ts +++ b/packages/ocom/ui-staff-route-tech-admin/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-staff-route-user-management/package.json b/packages/ocom/ui-staff-route-user-management/package.json index 45f5ed339..604ff613d 100644 --- a/packages/ocom/ui-staff-route-user-management/package.json +++ b/packages/ocom/ui-staff-route-user-management/package.json @@ -11,7 +11,6 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/ocom/ui-staff-route-user-management/src/vite-env.d.ts b/packages/ocom/ui-staff-route-user-management/src/vite-env.d.ts index 33b90b189..2ec5dab8d 100644 --- a/packages/ocom/ui-staff-route-user-management/src/vite-env.d.ts +++ b/packages/ocom/ui-staff-route-user-management/src/vite-env.d.ts @@ -1,2 +1 @@ /// - diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json index 7d5f8cdc6..c8c36c54e 100644 --- a/packages/ocom/ui-staff-shared/package.json +++ b/packages/ocom/ui-staff-shared/package.json @@ -12,7 +12,6 @@ "build": "tsgo --noEmit", "lint": "biome lint", "test": "vitest run --silent --reporter=dot", - "test:coverage": "vitest run --coverage --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37a489cb1..5ac55583c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,7 @@ overrides: yaml@2.8.2: 2.8.3 yauzl@3.2.0: 3.2.1 qs: ^6.15.2 + express@4.22.1: 4.22.2 ajv@^6: 6.14.0 lodash: 4.18.1 lodash-es: 4.18.1 @@ -138,6 +139,8 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 ws: 8.20.1 +packageExtensionsChecksum: sha256-mDviJarBPcwNNCTUf3T37btBxDGgV1wZ/iUGQfx5OCA= + patchedDependencies: '@azure/functions@4.11.0': 69772ce521bf6df67d814ff4f419f19b5e966a41c4ce80b5938143ad628e5645 @@ -906,11 +909,11 @@ importers: packages/cellix/server-oauth2-mock-seedwork: dependencies: express: - specifier: ^4.22.0 - version: 4.22.1 + specifier: ^4.22.2 + version: 4.22.2 express-rate-limit: specifier: 8.5.1 - version: 8.5.1(express@4.22.1) + version: 8.5.1(express@4.22.2) jose: specifier: ^5.9.6 version: 5.10.0 @@ -957,25 +960,25 @@ importers: version: link:../config-vitest '@chromatic-com/storybook': specifier: ^4.1.1 - version: 4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) '@storybook/addon-a11y': specifier: ^9.1.3 - version: 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) '@storybook/addon-docs': specifier: ^9.1.3 - version: 9.1.16(@types/react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 9.1.16(@types/react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) '@storybook/addon-onboarding': specifier: ^9.1.3 - version: 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) '@storybook/addon-vitest': specifier: ^9.1.3 - version: 9.1.20(@vitest/browser-playwright@4.1.2)(@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(@vitest/runner@4.1.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vitest@4.1.2) + version: 9.1.20(@vitest/browser-playwright@4.1.2)(@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(@vitest/runner@4.1.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vitest@4.1.2) '@storybook/react': specifier: ^9.1.9 - version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3) + version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3) '@storybook/react-vite': specifier: ^9.1.3 - version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@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) @@ -987,10 +990,10 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitest/browser': specifier: ^4.1.2 - version: 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + version: 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) '@vitest/browser-playwright': specifier: ^4.1.2 - version: 4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + version: 4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.2(vitest@4.1.2) @@ -1008,13 +1011,13 @@ importers: version: 6.0.1 storybook: specifier: 'catalog:' - version: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: 'catalog:' version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom-verification/acceptance-api: dependencies: @@ -1082,9 +1085,15 @@ importers: packages/ocom-verification/acceptance-ui: dependencies: + '@apollo/client': + specifier: ^3.13.9 + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.20.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cucumber/cucumber': specifier: 'catalog:' version: 12.8.1 + '@dr.pogodin/react-helmet': + specifier: ^3.0.4 + version: 3.0.4(react@19.2.0) '@serenity-js/console-reporter': specifier: 'catalog:' version: 3.42.2 @@ -1097,6 +1106,21 @@ importers: '@serenity-js/serenity-bdd': specifier: 'catalog:' version: 3.42.2 + antd: + specifier: 'catalog:' + version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + graphql: + specifier: 'catalog:' + version: 16.12.0 + react: + specifier: ^19.1.0 + version: 19.2.0 + react-dom: + specifier: ^19.1.0 + version: 19.2.0(react@19.2.0) + react-oidc-context: + specifier: ^3.3.0 + version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) std-env: specifier: ^4.0.0 version: 4.0.0 @@ -1125,12 +1149,6 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 - react: - specifier: ^19.1.0 - version: 19.2.0 - react-dom: - specifier: ^19.1.0 - version: 19.2.0(react@19.2.0) tsx: specifier: ^4.20.3 version: 4.21.0 @@ -1210,6 +1228,9 @@ importers: '@apollo/server': specifier: 'catalog:' version: 5.5.0(graphql@16.12.0) + '@cellix/server-mongodb-memory-mock-seedwork': + specifier: workspace:* + version: link:../../cellix/server-mongodb-memory-mock-seedwork '@cucumber/cucumber': specifier: 'catalog:' version: 12.8.1 @@ -1240,9 +1261,6 @@ importers: mongodb: specifier: 'catalog:' version: 6.18.0 - mongodb-memory-server: - specifier: ^10.2.0 - version: 10.3.0 mongoose: specifier: 'catalog:' version: 8.17.0 @@ -7275,8 +7293,8 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} body-parser@2.2.2: @@ -8493,10 +8511,10 @@ packages: resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} engines: {node: '>= 16'} peerDependencies: - express: '>= 4.11' + express: 4.22.2 - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} engines: {node: '>= 0.10.0'} extend-shallow@2.0.1: @@ -11465,8 +11483,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} raw-body@3.0.2: @@ -13955,6 +13973,7 @@ snapshots: '@azure/functions-opentelemetry-instrumentation@0.1.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -14901,6 +14920,18 @@ snapshots: '@blazediff/core@1.9.1': {} + '@chromatic-com/storybook@4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@neoconfetti/react': 1.0.0 + chromatic: 13.3.4 + filesize: 10.1.6 + jsonfile: 6.2.0 + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + strip-ansi: 7.1.2 + transitivePeerDependencies: + - '@chromatic-com/cypress' + - '@chromatic-com/playwright' + '@chromatic-com/storybook@4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@neoconfetti/react': 1.0.0 @@ -16936,6 +16967,15 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + glob: 10.5.0 + magic-string: 0.30.21 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + optionalDependencies: + typescript: 6.0.3 + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: glob: 10.5.0 @@ -18364,12 +18404,31 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-a11y@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@storybook/global': 5.0.0 + axe-core: 4.11.0 + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/addon-a11y@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.0 storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/addon-docs@9.1.16(@types/react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.0) + '@storybook/csf-plugin': 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + '@storybook/icons': 1.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@storybook/react-dom-shim': 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + '@storybook/addon-docs@9.1.16(@types/react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.0) @@ -18383,6 +18442,10 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@storybook/addon-onboarding@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/addon-onboarding@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -18403,6 +18466,22 @@ snapshots: - react - react-dom + '@storybook/addon-vitest@9.1.20(@vitest/browser-playwright@4.1.2)(@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(@vitest/runner@4.1.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vitest@4.1.2)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 1.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + prompts: 2.4.2 + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + ts-dedent: 2.2.0 + optionalDependencies: + '@vitest/browser': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + '@vitest/browser-playwright': 4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + '@vitest/runner': 4.1.2 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - react + - react-dom + '@storybook/addon-vitest@9.1.20(@vitest/browser-playwright@4.1.2)(@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(@vitest/runner@4.1.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vitest@4.1.2)': dependencies: '@storybook/global': 5.0.0 @@ -18419,6 +18498,13 @@ snapshots: - react - react-dom + '@storybook/builder-vite@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@storybook/csf-plugin': 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + ts-dedent: 2.2.0 + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@storybook/builder-vite@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@storybook/csf-plugin': 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) @@ -18426,6 +18512,11 @@ snapshots: ts-dedent: 2.2.0 vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@storybook/csf-plugin@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + unplugin: 1.16.1 + '@storybook/csf-plugin@9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -18438,12 +18529,38 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@storybook/react-dom-shim@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/react-dom-shim@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/react-vite@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@rollup/pluginutils': 5.3.0 + '@storybook/builder-vite': 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@storybook/react': 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3) + find-up: 7.0.0 + magic-string: 0.30.21 + react: 19.2.0 + react-docgen: 8.0.2 + react-dom: 19.2.0(react@19.2.0) + resolve: 1.22.11 + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + tsconfig-paths: 4.2.0 + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + '@storybook/react-vite@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -18464,6 +18581,16 @@ snapshots: - supports-color - typescript + '@storybook/react@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + storybook: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + typescript: 6.0.3 + '@storybook/react@9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 @@ -18967,6 +19094,19 @@ snapshots: - vite optional: true + '@vitest/browser-playwright@4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': + dependencies: + '@vitest/browser': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + '@vitest/mocker': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + playwright: 1.59.0 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/browser-playwright@4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': dependencies: '@vitest/browser': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) @@ -18998,6 +19138,23 @@ snapshots: - vite optional: true + '@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': dependencies: '@blazediff/core': 1.9.1 @@ -19048,6 +19205,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.4(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@3.2.4(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 @@ -19064,6 +19229,14 @@ snapshots: optionalDependencies: vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 @@ -19593,7 +19766,7 @@ snapshots: args: 5.0.3 axios: 1.15.2 etag: 1.8.1 - express: 4.22.1 + express: 4.22.2 fs-extra: 11.3.2 glob-to-regexp: 0.4.1 jsonwebtoken: 9.0.2 @@ -19703,18 +19876,18 @@ snapshots: bn.js@5.2.3: {} - body-parser@1.20.3: + body-parser@1.20.5: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 qs: 6.15.2 - raw-body: 2.5.2 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -21106,16 +21279,16 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.5.1(express@4.22.1): + express-rate-limit@8.5.1(express@4.22.2): dependencies: - express: 4.22.1 + express: 4.22.2 ip-address: 10.2.0 - express@4.22.1: + express@4.22.2: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.5 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -24618,10 +24791,10 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 @@ -25604,6 +25777,28 @@ snapshots: graphql: 16.12.0 react: 19.2.0 + storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@storybook/global': 5.0.0 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/spy': 3.2.4 + better-opn: 3.0.2 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + recast: 0.23.11 + semver: 7.7.4 + ws: 8.20.1 + transitivePeerDependencies: + - '@testing-library/dom' + - bufferutil + - msw + - supports-color + - utf-8-validate + - vite + storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@storybook/global': 5.0.0 @@ -26399,6 +26594,26 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.12(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.4.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -26449,6 +26664,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.10.1 + '@vitest/browser-playwright': 4.1.2(playwright@1.59.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 @@ -26557,7 +26802,7 @@ snapshots: colorette: 2.0.20 compression: 1.8.1 connect-history-api-fallback: 2.0.0 - express: 4.22.1 + express: 4.22.2 graceful-fs: 4.2.11 http-proxy-middleware: 2.0.9(@types/express@4.17.25) ipaddr.js: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f1cc989c..707fd922d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,7 @@ overrides: 'yaml@2.8.2': 2.8.3 'yauzl@3.2.0': 3.2.1 qs: ^6.15.2 + 'express@4.22.1': 4.22.2 'ajv@^6': 6.14.0 lodash: 4.18.1 lodash-es: 4.18.1 @@ -96,5 +97,10 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 ws: 8.20.1 +packageExtensions: + '@azure/functions-opentelemetry-instrumentation@0.1.0': + dependencies: + '@opentelemetry/api-logs': 0.57.2 + patchedDependencies: '@azure/functions@4.11.0': patches/@azure__functions@4.11.0.patch diff --git a/readme.md b/readme.md index 7f0e592c1..b1fcdbd19 100644 --- a/readme.md +++ b/readme.md @@ -326,6 +326,7 @@ flowchart BT This section preserves prior setup notes and commands for reference as the repo evolved. + ```bash npm i -D concurrently diff --git a/sonar-project.properties b/sonar-project.properties index 141305444..e5b7d641a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,7 +10,6 @@ sonar.projectVersion=1.0.0 sonar.sources=apps/api/src,\ apps/docs/src,\ apps/ui-community/src,\ -packages/cellix/archunit-tests/src,\ packages/cellix/api-services-spec/src,\ packages/cellix/config-rolldown/src,\ packages/cellix/domain-seedwork/src,\ @@ -36,12 +35,21 @@ packages/ocom/service-blob-storage/src,\ packages/ocom/service-mongoose/src,\ packages/ocom/service-otel/src,\ packages/ocom/service-token-validation/src,\ +packages/ocom/ui-community-route-accounts/src,\ +packages/ocom/ui-community-route-admin/src,\ +packages/ocom/ui-community-route-root/src,\ +packages/ocom/ui-community-shared/src,\ +packages/ocom/ui-staff-route-community-management/src,\ +packages/ocom/ui-staff-route-finance/src,\ +packages/ocom/ui-staff-route-root/src,\ +packages/ocom/ui-staff-route-tech-admin/src,\ +packages/ocom/ui-staff-route-user-management/src,\ +packages/ocom/ui-staff-shared/src,\ packages/ocom/ui-shared/src sonar.tests=apps/api/src,\ apps/docs/src,\ apps/ui-community/src,\ -packages/cellix/archunit-tests/src,\ packages/cellix/api-services-spec/src,\ packages/cellix/config-rolldown/src,\ packages/cellix/domain-seedwork/src,\ @@ -68,6 +76,16 @@ packages/ocom/service-blob-storage/src,\ packages/ocom/service-mongoose/src,\ packages/ocom/service-otel/src,\ packages/ocom/service-token-validation/src,\ +packages/ocom/ui-community-route-accounts/src,\ +packages/ocom/ui-community-route-admin/src,\ +packages/ocom/ui-community-route-root/src,\ +packages/ocom/ui-community-shared/src,\ +packages/ocom/ui-staff-route-community-management/src,\ +packages/ocom/ui-staff-route-finance/src,\ +packages/ocom/ui-staff-route-root/src,\ +packages/ocom/ui-staff-route-tech-admin/src,\ +packages/ocom/ui-staff-route-user-management/src,\ +packages/ocom/ui-staff-shared/src,\ packages/ocom/ui-shared/src # Test file patterns diff --git a/turbo.json b/turbo.json index 0ef199ae0..3f1ccbe30 100644 --- a/turbo.json +++ b/turbo.json @@ -78,6 +78,13 @@ "outputs": ["target/**", "reports/**"], "cache": false }, + "test:coverage:acceptance": { + "description": "Runs acceptance test coverage (c8 + Cucumber) for verification packages", + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"], + "outputs": ["coverage/**"], + "cache": false + }, "test:serenity": { "description": "Runs SerenityJS end-to-end test suites", "dependsOn": ["^build"],