From ba948492cef937b36842ab471c445d8056097f9e Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Mon, 1 Jun 2026 16:30:57 -0400 Subject: [PATCH 1/7] initial commit for verify --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index 886223cc6..a997ba1ed 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ Domain-driven architecture for Azure Functions with GraphQL/REST, MongoDB (Mongo ## Introduction + [Getting Started](https://developers.cellixjs.org/docs/intro): Our Docusaurus website will help you get started with running and contributing to CellixJS From 9be2b2ddc06c1fa70f091b7c1e47a8a499ca5888 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Tue, 2 Jun 2026 10:26:02 -0400 Subject: [PATCH 2/7] modularize serenity framework in a way that can be more easily used by consumers of cellix --- knip.json | 24 +- packages/cellix/serenity-framework/.gitignore | 1 + packages/cellix/serenity-framework/README.md | 131 +++++ .../cellix/serenity-framework/manifest.md | 64 +++ .../cellix/serenity-framework/package.json | 158 ++++++ .../src/clients/graphql-client.test.ts | 38 ++ .../src/clients/graphql-client.ts | 99 ++++ .../serenity-framework/src/clients/index.ts | 2 + .../src/cucumber/actor-name.ts | 22 + .../src/cucumber/cucumber.test.ts | 22 + .../src/cucumber/gherkin-data-table.ts | 29 + .../serenity-framework/src/cucumber/hooks.ts | 4 + .../serenity-framework/src/cucumber/index.ts | 7 + .../src/cucumber/lifecycle-hooks.ts | 47 ++ .../src/cucumber/screenshot-hooks.ts | 53 ++ .../src/cucumber/world.test.ts | 25 + .../serenity-framework/src/cucumber/world.ts | 88 +++ .../src/formatters/agent-formatter.ts | 44 +- .../cellix/serenity-framework/src/index.ts | 11 + .../src/infrastructure/api-infrastructure.ts | 244 +++++++++ .../src/infrastructure/api.ts | 11 + .../src/infrastructure/e2e-infrastructure.ts | 308 +++++++++++ .../src/infrastructure/e2e.ts | 9 + .../src/infrastructure/index.ts | 4 + .../src/infrastructure/infrastructure.test.ts | 158 ++++++ .../src/jsdom/asset-loader-hooks.ts | 54 ++ .../src/jsdom/css-module-types.d.ts | 4 + .../src/jsdom/css-modules.ts | 1 + .../serenity-framework/src/jsdom}/jsdom.d.ts | 0 .../src/jsdom/react-render.ts | 47 ++ .../src/jsdom/register-asset-loader.ts | 10 + .../serenity-framework/src/jsdom/setup.ts} | 0 .../src/pages/adapters/jsdom-adapter.ts | 117 ++-- .../src/pages/adapters/playwright-adapter.ts | 27 +- .../serenity-framework/src/pages/index.ts | 12 + .../src/pages/page-adapter.ts | 103 ++++ .../src/pages/page-object.test.ts | 21 + .../src/pages/page-object.ts | 38 ++ .../src/serenity}/browse-the-web.ts | 38 +- .../src/serenity/browser.ts | 1 + .../serenity-framework/src/serenity/cast.ts | 44 ++ .../serenity-framework/src/serenity/index.ts | 3 + .../src/serenity/task-step.test.ts | 16 + .../src/serenity/task-step.ts | 29 + .../src/servers/api-test-server.ts | 13 + .../src/servers/apollo-graphql-test-server.ts | 94 ++++ .../src/servers/auth-test-server.ts | 12 + .../src/servers/azurite-test-server.ts | 13 + .../serenity-framework/src/servers/index.ts | 18 + .../src/servers/mongo-memory-test-server.ts | 122 +++++ .../src/servers/process-environment.ts | 11 + .../src/servers/process-test-server.ts | 293 ++++++++++ .../src/servers/server-group.test.ts | 45 ++ .../src/servers/test-server-group.ts | 54 ++ .../src/servers/test-server.ts | 21 + .../src/servers/ui-portal-test-server.ts | 13 + .../serenity-framework/src/settings/index.ts | 2 + .../src/settings/settings.test.ts | 22 + .../src/settings/timeout-settings.ts | 93 ++++ .../cellix/serenity-framework/tsconfig.json | 12 + .../serenity-framework/tsconfig.vitest.json | 3 + packages/cellix/serenity-framework/turbo.json | 3 + .../serenity-framework/vitest.config.ts | 8 + .../acceptance-api/cucumber.js | 2 +- .../acceptance-api/package.json | 6 + .../header-types.ts => notes/header-notes.ts} | 0 .../step-definitions/header-login.steps.ts | 2 +- .../tasks/click-header-sign-in.ts | 4 +- .../community-notes.ts} | 0 .../community/questions/community-name.ts | 4 +- .../community/questions/community-status.ts | 2 +- .../create-community.steps.ts | 22 +- .../community/tasks/create-community.ts | 43 +- .../src/shared/abilities/create-community.ts | 47 ++ .../src/shared/abilities/graphql-client.ts | 48 +- .../src/shared/abilities/index.ts | 2 + .../application-services/index.ts | 0 .../mock-application-services.ts | 0 .../src/shared/cucumber-lifecycle-hooks.ts | 24 + .../src/shared/shared-infrastructure.ts | 39 ++ .../acceptance-api/src/shared/support/cast.ts | 10 - .../src/shared/support/domain-test-helpers.ts | 1 - .../src/shared/support/hooks.ts | 31 -- .../shared/support/shared-infrastructure.ts | 51 -- .../src/shared/test-server-factories.ts | 36 ++ .../acceptance-api/src/world.ts | 48 +- .../acceptance-ui/cucumber.js | 2 +- .../acceptance-ui/package.json | 8 +- .../header-types.ts => notes/header-notes.ts} | 0 .../step-definitions/header-login.steps.tsx | 7 +- .../tasks/click-header-sign-in.ts | 9 +- .../community-notes.ts} | 0 .../questions/community-created-flag.ts | 2 +- .../questions/community-error-message.ts | 2 +- .../community/questions/community-name.ts | 2 +- .../create-community.steps.tsx | 22 +- .../community/tasks/create-community.ts | 9 +- .../src/shared/cucumber-lifecycle-hooks.ts | 17 + .../src/shared/ocom-component-wrapper.ts | 13 + .../src/shared/page-contracts.ts | 5 + .../acceptance-ui/src/shared/support/cast.ts | 12 - .../acceptance-ui/src/shared/support/hooks.ts | 15 - .../shared/support/ui/asset-loader-hooks.mjs | 34 -- .../src/shared/support/ui/react-render.ts | 31 -- .../support/ui/register-asset-loader.ts | 10 - .../src/shared/support/ui/setup-jsdom.ts | 5 - .../src/step-definitions/index.ts | 4 +- .../acceptance-ui/src/world.ts | 25 +- .../acceptance-ui/tsconfig.json | 2 +- .../ocom-verification/e2e-tests/cucumber.js | 2 +- .../ocom-verification/e2e-tests/package.json | 1 + .../header-types.ts => notes/header-notes.ts} | 0 .../step-definitions/header-login.steps.ts | 4 +- .../tasks/click-header-sign-in.ts | 9 +- .../community-notes.ts} | 0 .../questions/community-created-flag.ts | 2 +- .../questions/community-error-message.ts | 2 +- .../community/questions/community-name.ts | 2 +- .../create-community.steps.ts | 14 +- .../community/tasks/create-community.ts | 11 +- .../src/shared/abilities/oauth2-login.ts | 108 ++++ .../src/shared/cucumber-lifecycle-hooks.ts | 24 + .../servers => environment}/app-paths.ts | 2 +- .../servers => environment}/dev-script.ts | 0 .../resolve-portless.ts | 2 +- .../shared/environment/test-environment.ts | 108 ++++ .../servers => environment}/worktree-ports.ts | 0 .../e2e-tests/src/shared/page-contracts.ts | 5 + .../src/shared/shared-infrastructure.ts | 60 ++ .../e2e-tests/src/shared/support/cast.ts | 13 - .../e2e-tests/src/shared/support/hooks.ts | 47 -- .../src/shared/support/oauth2-login.ts | 81 --- .../support/servers/child-process-env.ts | 4 - .../src/shared/support/servers/index.ts | 17 - .../shared/support/servers/portless-server.ts | 201 ------- .../shared/support/servers/test-api-server.ts | 66 --- .../support/servers/test-azurite-server.ts | 124 ----- .../servers/test-community-vite-server.ts | 46 -- .../support/servers/test-environment.ts | 70 --- .../support/servers/test-oauth2-server.ts | 37 -- .../support/servers/test-staff-vite-server.ts | 46 -- .../shared/support/shared-infrastructure.ts | 190 ------- .../src/shared/test-server-factories.ts | 108 ++++ .../e2e-tests/src/step-definitions/index.ts | 2 +- .../ocom-verification/e2e-tests/src/world.ts | 52 +- .../verification-shared/package.json | 29 +- .../src/abilities/create-community.ts | 53 ++ .../src/abilities/index.ts | 2 + .../src/formatters/index.ts | 1 - .../src/helpers/actor-helpers.ts | 12 - .../src/helpers/date-helpers.ts | 22 - .../src/helpers/gherkin-helpers.ts | 10 - .../verification-shared/src/helpers/index.ts | 10 - .../src/helpers/user-helpers.ts | 20 - .../src/pages/community.page.ts | 62 +-- .../src/pages/home.page.ts | 11 +- .../verification-shared/src/pages/index.ts | 16 - .../src/pages/login.page.ts | 34 -- .../src/pages/page-adapter.ts | 38 -- .../community.page-interface.ts | 5 - .../page-interfaces/home.page-interface.ts | 5 - .../src/pages/page-interfaces/index.ts | 12 - .../page-interfaces/login.page-interface.ts | 5 - .../verification-shared/src/serenity/index.ts | 1 - .../src/serenity/task-step.ts | 14 - .../src/servers/graphql-test-server.ts | 111 ---- .../verification-shared/src/servers/index.ts | 8 - .../src/servers/test-mongodb-server.ts | 130 ----- .../src/servers/test-server.interface.ts | 18 - .../verification-shared/src/settings/index.ts | 2 - .../src/settings/portless-settings.ts | 58 -- .../src/settings/timeout-settings.ts | 57 -- .../src/test-data/seed/end-users.ts | 5 +- .../src/test-data/test-actors.ts | 10 +- .../src/test-data/utils.ts | 4 +- pnpm-lock.yaml | 513 ++++++++++-------- pnpm-workspace.yaml | 6 +- 177 files changed, 4057 insertions(+), 2365 deletions(-) create mode 100644 packages/cellix/serenity-framework/.gitignore create mode 100644 packages/cellix/serenity-framework/README.md create mode 100644 packages/cellix/serenity-framework/manifest.md create mode 100644 packages/cellix/serenity-framework/package.json create mode 100644 packages/cellix/serenity-framework/src/clients/graphql-client.test.ts create mode 100644 packages/cellix/serenity-framework/src/clients/graphql-client.ts create mode 100644 packages/cellix/serenity-framework/src/clients/index.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/actor-name.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/hooks.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/index.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/world.test.ts create mode 100644 packages/cellix/serenity-framework/src/cucumber/world.ts rename packages/{ocom-verification/verification-shared => cellix/serenity-framework}/src/formatters/agent-formatter.ts (77%) create mode 100644 packages/cellix/serenity-framework/src/index.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/api.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/e2e.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/index.ts create mode 100644 packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts create mode 100644 packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts create mode 100644 packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts create mode 100644 packages/cellix/serenity-framework/src/jsdom/css-modules.ts rename packages/{ocom-verification/acceptance-ui/src/shared/support/ui => cellix/serenity-framework/src/jsdom}/jsdom.d.ts (100%) create mode 100644 packages/cellix/serenity-framework/src/jsdom/react-render.ts create mode 100644 packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts rename packages/{ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts => cellix/serenity-framework/src/jsdom/setup.ts} (100%) rename packages/{ocom-verification/verification-shared => cellix/serenity-framework}/src/pages/adapters/jsdom-adapter.ts (57%) rename packages/{ocom-verification/verification-shared => cellix/serenity-framework}/src/pages/adapters/playwright-adapter.ts (76%) create mode 100644 packages/cellix/serenity-framework/src/pages/index.ts create mode 100644 packages/cellix/serenity-framework/src/pages/page-adapter.ts create mode 100644 packages/cellix/serenity-framework/src/pages/page-object.test.ts create mode 100644 packages/cellix/serenity-framework/src/pages/page-object.ts rename packages/{ocom-verification/e2e-tests/src/shared/abilities => cellix/serenity-framework/src/serenity}/browse-the-web.ts (61%) create mode 100644 packages/cellix/serenity-framework/src/serenity/browser.ts create mode 100644 packages/cellix/serenity-framework/src/serenity/cast.ts create mode 100644 packages/cellix/serenity-framework/src/serenity/index.ts create mode 100644 packages/cellix/serenity-framework/src/serenity/task-step.test.ts create mode 100644 packages/cellix/serenity-framework/src/serenity/task-step.ts create mode 100644 packages/cellix/serenity-framework/src/servers/api-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/auth-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/azurite-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/index.ts create mode 100644 packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/process-environment.ts create mode 100644 packages/cellix/serenity-framework/src/servers/process-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/server-group.test.ts create mode 100644 packages/cellix/serenity-framework/src/servers/test-server-group.ts create mode 100644 packages/cellix/serenity-framework/src/servers/test-server.ts create mode 100644 packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts create mode 100644 packages/cellix/serenity-framework/src/settings/index.ts create mode 100644 packages/cellix/serenity-framework/src/settings/settings.test.ts create mode 100644 packages/cellix/serenity-framework/src/settings/timeout-settings.ts create mode 100644 packages/cellix/serenity-framework/tsconfig.json create mode 100644 packages/cellix/serenity-framework/tsconfig.vitest.json create mode 100644 packages/cellix/serenity-framework/turbo.json create mode 100644 packages/cellix/serenity-framework/vitest.config.ts rename packages/ocom-verification/acceptance-api/src/contexts/authentication/{abilities/header-types.ts => notes/header-notes.ts} (100%) rename packages/ocom-verification/acceptance-api/src/contexts/community/{abilities/community-types.ts => notes/community-notes.ts} (100%) create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts rename packages/ocom-verification/acceptance-api/src/shared/{support => }/application-services/index.ts (100%) rename packages/ocom-verification/acceptance-api/src/shared/{support => }/application-services/mock-application-services.ts (100%) create mode 100644 packages/ocom-verification/acceptance-api/src/shared/cucumber-lifecycle-hooks.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/shared/support/cast.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/test-server-factories.ts rename packages/ocom-verification/acceptance-ui/src/contexts/authentication/{abilities/header-types.ts => notes/header-notes.ts} (100%) rename packages/ocom-verification/acceptance-ui/src/contexts/community/{abilities/community-types.ts => notes/community-notes.ts} (100%) create mode 100644 packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts rename packages/ocom-verification/e2e-tests/src/contexts/authentication/{abilities/header-types.ts => notes/header-notes.ts} (100%) rename packages/ocom-verification/e2e-tests/src/contexts/community/{abilities/community-types.ts => notes/community-notes.ts} (100%) create mode 100644 packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts create mode 100644 packages/ocom-verification/e2e-tests/src/shared/cucumber-lifecycle-hooks.ts rename packages/ocom-verification/e2e-tests/src/shared/{support/servers => environment}/app-paths.ts (86%) rename packages/ocom-verification/e2e-tests/src/shared/{support/servers => environment}/dev-script.ts (100%) rename packages/ocom-verification/e2e-tests/src/shared/{support/servers => environment}/resolve-portless.ts (89%) create mode 100644 packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts rename packages/ocom-verification/e2e-tests/src/shared/{support/servers => environment}/worktree-ports.ts (100%) create mode 100644 packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts create mode 100644 packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/cast.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts create mode 100644 packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts create mode 100644 packages/ocom-verification/verification-shared/src/abilities/create-community.ts create mode 100644 packages/ocom-verification/verification-shared/src/abilities/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/formatters/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts delete mode 100644 packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts delete mode 100644 packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts delete mode 100644 packages/ocom-verification/verification-shared/src/helpers/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/login.page.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/page-adapter.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts delete mode 100644 packages/ocom-verification/verification-shared/src/serenity/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/serenity/task-step.ts delete mode 100644 packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts delete mode 100644 packages/ocom-verification/verification-shared/src/servers/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts delete mode 100644 packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts delete mode 100644 packages/ocom-verification/verification-shared/src/settings/index.ts delete mode 100644 packages/ocom-verification/verification-shared/src/settings/portless-settings.ts delete mode 100644 packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts diff --git a/knip.json b/knip.json index 130ffcb7f..859113a5c 100644 --- a/knip.json +++ b/knip.json @@ -24,6 +24,11 @@ "project": ["src/**/*.ts"], "ignore": ["**/graphql-tools-scalars.ts"] }, + "packages/cellix/serenity-framework": { + "project": ["src/**/*.ts"], + "ignore": ["**/graphql-tools-scalars.ts"], + "ignoreDependencies": ["@testing-library/react", "playwright", "react"] + }, "packages/cellix/mongoose-seedwork": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"], @@ -62,20 +67,11 @@ "project": ["src/**/*.{ts,cjs}"] }, "packages/ocom-verification/verification-shared": { - "entry": [ - "src/formatters/index.ts", - "src/helpers/index.ts", - "src/pages/index.ts", - "src/pages/adapters/jsdom-adapter.ts", - "src/pages/adapters/playwright-adapter.ts", - "src/servers/index.ts", - "src/settings/index.ts", - "src/test-data/index.ts" - ], + "entry": ["src/abilities/index.ts", "src/pages/index.ts", "src/test-data/index.ts"], "project": ["src/**/*.ts"] }, "packages/ocom-verification/acceptance-api": { - "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], + "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts", "src/shared/abilities/index.ts"], "project": ["src/**/*.ts"], "ignoreBinaries": ["report"], "ignoreUnresolved": ["progress-bar"] @@ -83,11 +79,11 @@ "packages/ocom-verification/acceptance-ui": { "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], "project": ["src/**/*.{ts,tsx,mjs}"], - "ignoreUnresolved": ["progress-bar"], - "ignore": ["src/shared/support/ui/**"] + "ignore": ["src/shared/support/ui/**"], + "ignoreUnresolved": ["progress-bar"] }, "packages/ocom-verification/e2e-tests": { - "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/support/**/*.ts"], + "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/environment/**/*.ts", "src/shared/abilities/**/*.ts", "src/shared/test-server-factories.ts"], "project": ["src/**/*.ts"], "ignoreUnresolved": ["progress-bar"] }, diff --git a/packages/cellix/serenity-framework/.gitignore b/packages/cellix/serenity-framework/.gitignore new file mode 100644 index 000000000..53c37a166 --- /dev/null +++ b/packages/cellix/serenity-framework/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/cellix/serenity-framework/README.md b/packages/cellix/serenity-framework/README.md new file mode 100644 index 000000000..8490ef0e5 --- /dev/null +++ b/packages/cellix/serenity-framework/README.md @@ -0,0 +1,131 @@ +# @cellix/serenity-framework + +Reusable Serenity/JS verification framework primitives for Cellix packages. + +This package is intentionally app-agnostic. It provides adapters, a generic Serenity cast, Cucumber utilities, managed worlds, and server lifecycle infrastructure; consumers provide page objects, selectors, app paths, schemas, services, seed data, and environment-specific values. + +## Page adapters + +Page objects should depend on `PageAdapter`, not directly on jsdom or Playwright: + +```ts +import { AdapterBackedPageObject, type PageAdapter } from '@cellix/serenity-framework/pages'; + +class CommunityPage extends AdapterBackedPageObject { + constructor(adapter: PageAdapter) { + super(adapter); + } + + async createCommunity(name: string): Promise { + await this.adapter.getByPlaceholder('Name').fill(name); + await this.adapter.getByRole('button', { name: /Create/i }).click(); + } +} +``` + +Use the runtime-specific adapter at the edge of the test package: + +```ts +import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +``` + +## Server composition + +Use generic server descriptors for app-specific processes, then load them into the suite infrastructure manager instead of hand-writing suite startup code. + +```ts +import { E2EInfrastructure } from '@cellix/serenity-framework/infrastructure/e2e'; +import { ApiTestServer, AuthTestServer, AzuriteTestServer, UiPortalTestServer } from '@cellix/serenity-framework/servers'; + +const communityPortal = new UiPortalTestServer({ + portalName: 'community', + cwd: '/repo/apps/ui-community', + getUrl: () => 'https://community.localhost:1355', + spawnArgs: ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'], +}); + +export const infrastructure = E2EInfrastructure + .using({ + mongoServer: { dbName, port, replSetName, seedData }, + azuriteServer, + authServer, + createApiServer: ({ getMongoConnectionString }) => + new ApiTestServer({ + serverName: 'Api', + executable: 'pnpm', + spawnArgs: ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'], + cwd: '/repo/apps/api', + extraEnv: () => ({ COSMOSDB_CONNECTION_STRING: getMongoConnectionString() }), + getUrl: () => 'https://api.localhost:1355/api/graphql', + readyMarker: 'Functions:', + }), + launchBrowser: () => playwright.chromium.launch({ headless: true }), + }) + .addUiPortal('community', communityPortal) + .addUiPortal('staff', staffPortal); + +await infrastructure.ensureStarted(); +await infrastructure.resetScenarioState(); +await infrastructure.stopAll(); +``` + +The framework never imports app paths or app-specific environment helpers. Pass those values into descriptors from the consumer package. + +## API acceptance infrastructure + +API-only acceptance suites use the smaller infrastructure manager. It requires MongoDB options and an API server factory, and owns startup, URL state, reset, and shutdown. If the API needs a Mongoose service, pass a factory that returns the consumer's Mongoose-compatible service; the framework starts it, clears registered models, and stops it. + +```ts +import { ApiInfrastructure } from '@cellix/serenity-framework/infrastructure/api'; +import { ApolloGraphQLTestServer } from '@cellix/serenity-framework/servers'; + +export const infrastructure = ApiInfrastructure.using({ + mongoServer: { dbName, port, replSetName, seedData }, + mongoose: { + createService: (connectionString) => createMongooseService(connectionString), + }, + createApiServer: ({ getMongooseService }) => + new ApolloGraphQLTestServer({ + schema, + context: () => createContext(getMongooseService()), + }), +}); +``` + +## Managed worlds + +Use a managed world when the suite does not need custom Cucumber world methods. The framework starts infrastructure, validates state, engages the cast, and resets scenario state. + +```ts +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; + +export const ApiWorld = registerManagedSerenityWorld({ + infrastructure, + validateState: (state) => { + if (!state.apiUrl) throw new Error('API URL was not initialized'); + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [() => new GraphQLClient({ apiUrl: state.apiUrl ?? '' })], + }), +}); +``` + +## jsdom helpers + +Component-level acceptance tests can import framework jsdom setup and asset-loader hooks instead of carrying per-suite copies: + +```sh +NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js +``` + +```ts +import '@cellix/serenity-framework/jsdom/setup'; +/// Include `@cellix/serenity-framework/src/jsdom/css-module-types.d.ts` +/// in tsconfig when component imports include CSS modules. +import { mountComponent, unmountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +``` diff --git a/packages/cellix/serenity-framework/manifest.md b/packages/cellix/serenity-framework/manifest.md new file mode 100644 index 000000000..11d6563c4 --- /dev/null +++ b/packages/cellix/serenity-framework/manifest.md @@ -0,0 +1,64 @@ +# manifest.md - @cellix/serenity-framework + +## Purpose + +Provide reusable Serenity/JS, Cucumber, page-adapter, and test-server framework primitives that Cellix consumers can compose with app-specific pages, step definitions, services, URLs, and data. + +## Scope + +This package owns generic verification infrastructure only: + +- Serenity task, cast, and browser-ability primitives +- Cucumber data-table, lifecycle, screenshot, and managed-world helpers +- Runtime-agnostic page adapter contracts and jsdom/Playwright adapter implementations +- jsdom globals, CSS module declarations, asset-loader hooks, and generic React render helpers for component acceptance tests +- Adapter-backed page-object base contracts +- Timeout utilities +- Configurable process, UI portal, Apollo GraphQL, Azurite, Mongo memory, and server-group lifecycle utilities +- API acceptance and browser E2E infrastructure managers that create MongoDB from options and compose consumer-owned server factories + +## Non-goals + +- OCOM-specific page objects, selectors, scenarios, seed data, application services, GraphQL schemas, app paths, or environment variable names +- Opinionated Cucumber step definitions +- Production server orchestration + +## Public API shape + +- `@cellix/serenity-framework/serenity`: `TaskStep` +- `@cellix/serenity-framework/cucumber`: `ActorName`, `GherkinDataTable`, lifecycle hook helpers +- `@cellix/serenity-framework/cucumber/screenshot`: browser screenshot-on-failure hook helpers +- `@cellix/serenity-framework/pages`: adapter contracts and page-object base types +- `@cellix/serenity-framework/pages/jsdom`: `JsdomPageAdapter` +- `@cellix/serenity-framework/pages/playwright`: `PlaywrightPageAdapter` +- `@cellix/serenity-framework/clients/graphql`: `GraphQLClient` +- `@cellix/serenity-framework/jsdom/setup`: jsdom global bootstrap side-effect module +- `@cellix/serenity-framework/jsdom/register-asset-loader`: jsdom asset-loader registration side-effect module +- `@cellix/serenity-framework/jsdom/react-render`: generic React mount/unmount helpers +- `@cellix/serenity-framework/jsdom/css-modules`: package-owned CSS module declaration target +- `@cellix/serenity-framework/serenity`: `TaskStep`, `SerenityCast` +- `@cellix/serenity-framework/serenity/browser`: `BrowseTheWeb` +- `@cellix/serenity-framework/infrastructure/api`: API acceptance infrastructure manager with MongoDB options, optional Mongoose service management, and an API server factory +- `@cellix/serenity-framework/infrastructure/e2e`: browser E2E infrastructure manager with MongoDB options, required Azurite/auth servers, an API server factory, and chainable UI portals +- `@cellix/serenity-framework/servers`: generic server lifecycle classes and interfaces +- `@cellix/serenity-framework/settings`: timeout helpers + +## Package boundaries + +The package must not import from `@ocom/*`, `@ocom-verification/*`, `apps/*`, or local OCOM path helpers. Consumers pass app-specific values through options objects, descriptors, factories, or callbacks. + +## Dependencies / relationships + +Downstream consumers in this monorepo are expected to include `@ocom-verification/acceptance-api`, `@ocom-verification/acceptance-ui`, and `@ocom-verification/e2e-tests`. + +## Testing strategy + +Prefer public-entrypoint tests that exercise observable behavior through sectioned exports. Do not test private implementation details or deep-import package internals. + +## Documentation obligations + +Keep `README.md` consumer-facing and package-centric. Meaningful public exports require TSDoc that explains purpose, options, return values, side effects, errors, and usage where helpful. + +## Release-readiness standards + +Build and test this package plus affected verification consumers before treating the package as ready for external npm use. Any public export removal or behavioral incompatibility requires explicit semver review. diff --git a/packages/cellix/serenity-framework/package.json b/packages/cellix/serenity-framework/package.json new file mode 100644 index 000000000..fe50b41f5 --- /dev/null +++ b/packages/cellix/serenity-framework/package.json @@ -0,0 +1,158 @@ +{ + "name": "@cellix/serenity-framework", + "version": "1.0.0", + "description": "Reusable Serenity/JS verification framework primitives for Cellix packages", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src/jsdom/css-module-types.d.ts" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./clients/graphql": { + "types": "./dist/clients/graphql-client.d.ts", + "default": "./dist/clients/graphql-client.js" + }, + "./cucumber": { + "types": "./dist/cucumber/index.d.ts", + "default": "./dist/cucumber/index.js" + }, + "./cucumber/actor-name": { + "types": "./dist/cucumber/actor-name.d.ts", + "default": "./dist/cucumber/actor-name.js" + }, + "./cucumber/gherkin-data-table": { + "types": "./dist/cucumber/gherkin-data-table.d.ts", + "default": "./dist/cucumber/gherkin-data-table.js" + }, + "./cucumber/hooks": { + "types": "./dist/cucumber/hooks.d.ts", + "default": "./dist/cucumber/hooks.js" + }, + "./cucumber/screenshot": { + "types": "./dist/cucumber/screenshot-hooks.d.ts", + "default": "./dist/cucumber/screenshot-hooks.js" + }, + "./formatters/agent": { + "types": "./dist/formatters/agent-formatter.d.ts", + "default": "./dist/formatters/agent-formatter.js" + }, + "./infrastructure": { + "types": "./dist/infrastructure/index.d.ts", + "default": "./dist/infrastructure/index.js" + }, + "./infrastructure/api": { + "types": "./dist/infrastructure/api.d.ts", + "default": "./dist/infrastructure/api.js" + }, + "./infrastructure/e2e": { + "types": "./dist/infrastructure/e2e.d.ts", + "default": "./dist/infrastructure/e2e.js" + }, + "./pages": { + "types": "./dist/pages/index.d.ts", + "default": "./dist/pages/index.js" + }, + "./pages/jsdom": { + "types": "./dist/pages/adapters/jsdom-adapter.d.ts", + "default": "./dist/pages/adapters/jsdom-adapter.js" + }, + "./pages/playwright": { + "types": "./dist/pages/adapters/playwright-adapter.d.ts", + "default": "./dist/pages/adapters/playwright-adapter.js" + }, + "./jsdom/setup": { + "types": "./dist/jsdom/setup.d.ts", + "default": "./dist/jsdom/setup.js" + }, + "./jsdom/register-asset-loader": { + "types": "./dist/jsdom/register-asset-loader.d.ts", + "default": "./dist/jsdom/register-asset-loader.js" + }, + "./jsdom/asset-loader-hooks": { + "types": "./dist/jsdom/asset-loader-hooks.d.ts", + "default": "./dist/jsdom/asset-loader-hooks.js" + }, + "./jsdom/react-render": { + "types": "./dist/jsdom/react-render.d.ts", + "default": "./dist/jsdom/react-render.js" + }, + "./jsdom/css-modules": { + "types": "./src/jsdom/css-module-types.d.ts", + "default": "./dist/jsdom/css-modules.js" + }, + "./serenity": { + "types": "./dist/serenity/index.d.ts", + "default": "./dist/serenity/index.js" + }, + "./serenity/browser": { + "types": "./dist/serenity/browser.d.ts", + "default": "./dist/serenity/browser.js" + }, + "./servers": { + "types": "./dist/servers/index.d.ts", + "default": "./dist/servers/index.js" + }, + "./settings": { + "types": "./dist/settings/index.d.ts", + "default": "./dist/settings/index.js" + } + }, + "scripts": { + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "clean": "rimraf dist tsconfig.tsbuildinfo && tsgo --build --clean", + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format .", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest" + }, + "dependencies": { + "@apollo/server": "catalog:", + "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", + "@cucumber/cucumber": "catalog:", + "@cucumber/messages": "catalog:", + "@serenity-js/core": "catalog:", + "graphql": "catalog:", + "graphql-depth-limit": "^1.1.0", + "jsdom": "catalog:", + "mongodb": "catalog:" + }, + "peerDependencies": { + "@testing-library/react": ">=16.0.0", + "playwright": ">=1.50.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/react": { + "optional": true + }, + "playwright": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/graphql-depth-limit": "^1.1.0", + "@types/node": "catalog:", + "@types/react": "^19.1.8", + "@vitest/coverage-istanbul": "catalog:", + "playwright": "catalog:", + "react": "^19.1.0", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts new file mode 100644 index 000000000..67ee4081f --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GraphQLClient } from './index.ts'; + +describe('GraphQLClient', () => { + it('posts GraphQL operations with configured headers', async () => { + const fetcher = vi.fn(async () => Response.json({ data: { ok: true } })); + const client = new GraphQLClient({ + apiUrl: 'https://api.example.test/graphql', + fetch: fetcher as typeof fetch, + headers: { Authorization: 'Bearer test' }, + }); + + const response = await client.execute<{ ok: boolean }>('query Test { ok }', { id: 1 }); + + expect(response.data.ok).toBe(true); + expect(fetcher).toHaveBeenCalledWith( + 'https://api.example.test/graphql', + expect.objectContaining({ + body: JSON.stringify({ query: 'query Test { ok }', variables: { id: 1 } }), + headers: { + Authorization: 'Bearer test', + 'Content-Type': 'application/json', + }, + method: 'POST', + }), + ); + }); + + it('throws when GraphQL errors are returned', async () => { + const fetcher = vi.fn(async () => Response.json({ data: {}, errors: [{ message: 'Nope' }] })); + const client = new GraphQLClient({ + apiUrl: 'https://api.example.test/graphql', + fetch: fetcher as typeof fetch, + }); + + await expect(client.execute('query Test { ok }')).rejects.toThrow('Nope'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.ts new file mode 100644 index 000000000..49d851451 --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.ts @@ -0,0 +1,99 @@ +import { Ability } from '@serenity-js/core'; + +/** GraphQL error shape returned by common GraphQL HTTP servers. */ +export interface GraphQLResponseError { + /** Human-readable error message. */ + message: string; +} + +/** Result returned from {@link GraphQLClient.execute}. */ +export interface GraphQLResponse = Record> { + /** GraphQL response data. */ + data: TData; + + /** Optional GraphQL errors returned by the server. */ + errors?: GraphQLResponseError[]; +} + +/** Options used to create a GraphQL Serenity ability. */ +export interface GraphQLClientOptions { + /** GraphQL HTTP endpoint URL. */ + apiUrl: string; + + /** Headers applied to every request. */ + headers?: Record | (() => Record); + + /** Fetch implementation. Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; +} + +/** + * Serenity ability for executing GraphQL operations over HTTP. + * + * Consumers provide the endpoint and any app-specific headers, such as test + * authorization tokens. GraphQL errors are raised as JavaScript `Error`s so + * Screenplay questions and tasks fail the scenario clearly. + */ +export class GraphQLClient extends Ability { + private readonly apiUrl: string; + private readonly fetcher: typeof fetch; + private readonly headers: Record | (() => Record) | undefined; + + /** + * @param options Endpoint, headers, and optional fetch implementation. + */ + constructor(options: GraphQLClientOptions) { + super(); + this.apiUrl = options.apiUrl; + this.headers = options.headers; + this.fetcher = options.fetch ?? globalThis.fetch; + } + + /** + * Create a GraphQL ability for a specific endpoint. + * + * @param apiUrl GraphQL HTTP endpoint URL. + * @param headers Optional static or lazy headers applied to each request. + */ + static at(apiUrl: string, headers?: Record | (() => Record)): GraphQLClient { + return new GraphQLClient({ apiUrl, ...(headers && { headers }) }); + } + + /** + * Execute a GraphQL query or mutation. + * + * @param query GraphQL document text. + * @param variables Variables supplied to the operation. + * @throws Error when the HTTP response is not OK or the GraphQL result contains errors. + */ + async execute = Record>(query: string, variables: Record = {}): Promise> { + const response = await this.fetcher(this.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.resolveHeaders(), + }, + body: JSON.stringify({ query, variables }), + }); + + const result = (await response.json()) as GraphQLResponse; + + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message ?? 'Unknown error').join('; ')); + } + + if (!response.ok) { + throw new Error(`GraphQL error: ${response.status} ${response.statusText}`); + } + + return result; + } + + private resolveHeaders(): Record { + if (!this.headers) { + return {}; + } + + return typeof this.headers === 'function' ? this.headers() : this.headers; + } +} diff --git a/packages/cellix/serenity-framework/src/clients/index.ts b/packages/cellix/serenity-framework/src/clients/index.ts new file mode 100644 index 000000000..c30e09bc1 --- /dev/null +++ b/packages/cellix/serenity-framework/src/clients/index.ts @@ -0,0 +1,2 @@ +export type { GraphQLClientOptions, GraphQLResponse, GraphQLResponseError } from './graphql-client.ts'; +export { GraphQLClient } from './graphql-client.ts'; diff --git a/packages/cellix/serenity-framework/src/cucumber/actor-name.ts b/packages/cellix/serenity-framework/src/cucumber/actor-name.ts new file mode 100644 index 000000000..1cc4dba15 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/actor-name.ts @@ -0,0 +1,22 @@ +/** Options used when resolving actor references from Gherkin text. */ +export interface ActorNameResolutionOptions { + /** Name used when a pronoun is supplied. Defaults to `Alice`. */ + defaultName?: string; +} + +const pronounPattern = /^(she|he|they)$/i; + +/** + * Resolver object for actor names found in Gherkin steps. + */ +export const ActorName = { + /** + * Resolve pronouns such as `she`, `he`, or `they` to a default actor name. + * + * @param actorName Name or pronoun captured from a Gherkin step. + * @param options Optional default name configuration. + */ + resolve(actorName: string, options: ActorNameResolutionOptions = {}): string { + return pronounPattern.test(actorName) ? (options.defaultName ?? 'Alice') : actorName; + }, +} as const; diff --git a/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts b/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts new file mode 100644 index 000000000..a800484fc --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/cucumber.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { ActorName, GherkinDataTable } from './index.ts'; + +describe('ActorName', () => { + it('resolves pronouns to the configured default actor name', () => { + expect(ActorName.resolve('she', { defaultName: 'Casey' })).toBe('Casey'); + expect(ActorName.resolve('Morgan', { defaultName: 'Casey' })).toBe('Morgan'); + }); +}); + +describe('GherkinDataTable', () => { + it('returns a typed rows hash', () => { + const dataTable = { + rowsHash: () => ({ name: 'Evergreen', status: 'Active' }), + }; + + const row = GherkinDataTable.from(dataTable as never).rowsHash<{ name: string; status: string }>(); + + expect(row.name).toBe('Evergreen'); + expect(row.status).toBe('Active'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts b/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts new file mode 100644 index 000000000..dd084aa0a --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/gherkin-data-table.ts @@ -0,0 +1,29 @@ +import type { DataTable } from '@cucumber/cucumber'; + +/** + * Typed wrapper around a Cucumber `DataTable`. + */ +export class GherkinDataTable { + /** + * @param dataTable Cucumber data table received by a step definition. + */ + constructor(private readonly dataTable: DataTable) {} + + /** + * Return `rowsHash()` as a caller-provided object shape. + * + * @typeParam T Shape expected by the step definition. + */ + rowsHash(): T { + return this.dataTable.rowsHash() as T; + } + + /** + * Wrap a Cucumber data table. + * + * @param dataTable Cucumber data table received by a step definition. + */ + static from(dataTable: DataTable): GherkinDataTable { + return new GherkinDataTable(dataTable); + } +} diff --git a/packages/cellix/serenity-framework/src/cucumber/hooks.ts b/packages/cellix/serenity-framework/src/cucumber/hooks.ts new file mode 100644 index 000000000..bcf61416b --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/hooks.ts @@ -0,0 +1,4 @@ +export type { WorldLifecycleHooks } from './lifecycle-hooks.ts'; +export { registerWorldLifecycleHooks } from './lifecycle-hooks.ts'; +export type { ScreenshotOnFailureOptions } from './screenshot-hooks.ts'; +export { registerScreenshotOnFailureHook } from './screenshot-hooks.ts'; diff --git a/packages/cellix/serenity-framework/src/cucumber/index.ts b/packages/cellix/serenity-framework/src/cucumber/index.ts new file mode 100644 index 000000000..8f284ebb2 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/index.ts @@ -0,0 +1,7 @@ +export type { ActorNameResolutionOptions } from './actor-name.ts'; +export { ActorName } from './actor-name.ts'; +export { GherkinDataTable } from './gherkin-data-table.ts'; +export type { WorldLifecycleHooks } from './lifecycle-hooks.ts'; +export { registerWorldLifecycleHooks } from './lifecycle-hooks.ts'; +export type { ManagedSerenityWorldInfrastructure, ManagedSerenityWorldOptions } from './world.ts'; +export { createManagedSerenityWorldClass, ManagedSerenityWorld, registerManagedSerenityWorld } from './world.ts'; diff --git a/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts b/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts new file mode 100644 index 000000000..25ed667eb --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/lifecycle-hooks.ts @@ -0,0 +1,47 @@ +import { After, AfterAll, Before, type ITestCaseHookParameter, type IWorld, setDefaultTimeout } from '@cucumber/cucumber'; + +/** Lifecycle callbacks used by {@link registerWorldLifecycleHooks}. */ +export interface WorldLifecycleHooks { + /** Scenario timeout in milliseconds. */ + scenarioTimeout?: number; + + /** Optional timeout for the before hook. */ + beforeTimeout?: number; + + /** Optional timeout for the after hook. */ + afterTimeout?: number; + + /** Called before each scenario. */ + before?: (world: TWorld) => Promise | void; + + /** Called after each scenario. */ + after?: (world: TWorld, parameter: ITestCaseHookParameter) => Promise | void; + + /** Called once after all scenarios complete. */ + afterAll?: () => Promise | void; +} + +/** + * Register common Cucumber world lifecycle hooks. + * + * @param hooks Hook callbacks and timeouts for a test package. + */ +export function registerWorldLifecycleHooks(hooks: WorldLifecycleHooks): void { + if (hooks.scenarioTimeout) { + setDefaultTimeout(hooks.scenarioTimeout); + } + + Before(hooks.beforeTimeout === undefined ? {} : { timeout: hooks.beforeTimeout }, async function (this: IWorld) { + await hooks.before?.(this as TWorld); + }); + + After(hooks.afterTimeout === undefined ? {} : { timeout: hooks.afterTimeout }, async function (this: IWorld, parameter: ITestCaseHookParameter) { + await hooks.after?.(this as TWorld, parameter); + }); + + if (hooks.afterAll) { + AfterAll(async () => { + await hooks.afterAll?.(); + }); + } +} diff --git a/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts b/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts new file mode 100644 index 000000000..3743da140 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/screenshot-hooks.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { After, type ITestCaseHookParameter, type IWorld, Status } from '@cucumber/cucumber'; +import { BrowseTheWeb } from '../serenity/browse-the-web.ts'; + +/** Options used by {@link registerScreenshotOnFailureHook}. */ +export interface ScreenshotOnFailureOptions { + /** Directory where screenshots should be written. */ + reportsDir: string; + + /** Whether to capture screenshots on failure. Defaults to `false`. */ + enabled?: boolean; + + /** Ability provider. Defaults to `BrowseTheWeb.current()`. */ + getBrowseTheWeb?: () => BrowseTheWeb | undefined; +} + +/** + * Register a Cucumber `After` hook that captures a Playwright screenshot on failure. + * + * The hook is best-effort: screenshot failures are swallowed so the original + * scenario failure remains the primary signal. + * + * @param options Screenshot directory and optional browser ability provider. + */ +export function registerScreenshotOnFailureHook(options: ScreenshotOnFailureOptions): void { + if (!options.enabled) return; + + After(async function (this: IWorld, { result, pickle }: ITestCaseHookParameter) { + if (result?.status !== Status.FAILED) { + return; + } + + try { + const browseTheWeb = options.getBrowseTheWeb?.() ?? BrowseTheWeb.current(); + if (!browseTheWeb) { + return; + } + + const reportsDir = resolve(options.reportsDir); + mkdirSync(reportsDir, { recursive: true }); + + const safeName = pickle.name.replaceAll(/[^a-zA-Z0-9-_]/g, '_').slice(0, 80); + const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); + const screenshotPath = join(reportsDir, `${safeName}-${timestamp}.png`); + + await browseTheWeb.page.screenshot({ path: screenshotPath, fullPage: true }); + this.attach(readFileSync(screenshotPath), 'image/png'); + } catch { + /* Screenshot capture is best-effort. */ + } + }); +} diff --git a/packages/cellix/serenity-framework/src/cucumber/world.test.ts b/packages/cellix/serenity-framework/src/cucumber/world.test.ts new file mode 100644 index 000000000..ae6f7ff67 --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/world.test.ts @@ -0,0 +1,25 @@ +import type { Cast } from '@serenity-js/core'; +import { describe, expect, it, vi } from 'vitest'; +import { ManagedSerenityWorld } from './world.ts'; + +describe('ManagedSerenityWorld', () => { + it('starts infrastructure, validates state, creates the cast, and resets during cleanup', async () => { + const state = { apiUrl: 'https://api.test/graphql' }; + const infrastructure = { + ensureStarted: vi.fn(), + getState: vi.fn(() => state), + resetScenarioState: vi.fn(), + }; + const validateState = vi.fn(); + const createCast = vi.fn(() => ({ prepare: vi.fn() }) as unknown as Cast); + const world = new ManagedSerenityWorld({ attach: vi.fn(), log: vi.fn(), link: vi.fn(), parameters: {} }, { createCast, infrastructure, validateState }); + + await world.init(); + await world.cleanup(); + + expect(infrastructure.ensureStarted).toHaveBeenCalledOnce(); + expect(validateState).toHaveBeenCalledWith(state); + expect(createCast).toHaveBeenCalledWith(state); + expect(infrastructure.resetScenarioState).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cellix/serenity-framework/src/cucumber/world.ts b/packages/cellix/serenity-framework/src/cucumber/world.ts new file mode 100644 index 000000000..23e40cacf --- /dev/null +++ b/packages/cellix/serenity-framework/src/cucumber/world.ts @@ -0,0 +1,88 @@ +import { type IWorldOptions, setWorldConstructor, World } from '@cucumber/cucumber'; +import { type Cast, engage } from '@serenity-js/core'; + +/** Infrastructure shape consumed by managed Serenity worlds. */ +export interface ManagedSerenityWorldInfrastructure { + /** Start the suite infrastructure before the scenario uses actors. */ + ensureStarted: () => Promise; + + /** Reset mutable scenario state after each scenario. */ + resetScenarioState?: () => Promise; + + /** Stop suite infrastructure after all scenarios. */ + stopAll?: () => Promise; + + /** Return state needed to construct the scenario cast. */ + getState: () => TState; +} + +/** Options used by {@link ManagedSerenityWorld}. */ +export interface ManagedSerenityWorldOptions { + /** Infrastructure object that owns server and browser lifecycle. */ + infrastructure: ManagedSerenityWorldInfrastructure; + + /** Builds the cast after infrastructure has started. */ + createCast: (state: TState) => Cast; + + /** Optional state assertion run before the cast is engaged. */ + validateState?: (state: TState) => void; +} + +/** + * Base Cucumber world that wires infrastructure state into Serenity/JS. + * + * Extend this class when a suite needs app-specific world methods, or use + * {@link createManagedSerenityWorldClass} when the suite only needs `init` and + * `cleanup`. Consumers supply configuration; the repeated startup, cast + * engagement, and scenario reset pattern stays in the framework. + */ +export class ManagedSerenityWorld extends World { + /** + * @param options Cucumber world options. + * @param config Infrastructure and cast configuration. + */ + constructor( + options: IWorldOptions, + private readonly config: ManagedSerenityWorldOptions, + ) { + super(options); + } + + /** Start infrastructure and engage a Serenity cast for the scenario. */ + async init(): Promise { + await this.config.infrastructure.ensureStarted(); + const state = this.config.infrastructure.getState(); + this.config.validateState?.(state); + engage(this.config.createCast(state)); + } + + /** Reset scenario state through the configured infrastructure. */ + async cleanup(): Promise { + await this.config.infrastructure.resetScenarioState?.(); + } +} + +/** + * Create a Cucumber world class from managed Serenity world configuration. + * + * @param config Infrastructure and cast configuration. + */ +export function createManagedSerenityWorldClass(config: ManagedSerenityWorldOptions): typeof ManagedSerenityWorld { + return class ConfiguredManagedSerenityWorld extends ManagedSerenityWorld { + /** Create the configured world. */ + constructor(options: IWorldOptions) { + super(options, config); + } + }; +} + +/** + * Register a managed Serenity world with Cucumber and return the class. + * + * @param config Infrastructure and cast configuration. + */ +export function registerManagedSerenityWorld(config: ManagedSerenityWorldOptions): typeof ManagedSerenityWorld { + const WorldClass = createManagedSerenityWorldClass(config); + setWorldConstructor(WorldClass); + return WorldClass; +} diff --git a/packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts similarity index 77% rename from packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts rename to packages/cellix/serenity-framework/src/formatters/agent-formatter.ts index b2f8153fa..c06a3b36c 100644 --- a/packages/ocom-verification/verification-shared/src/formatters/agent-formatter.ts +++ b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts @@ -4,27 +4,37 @@ import type { Envelope, TestCaseFinished, TestRunFinished, TestRunStarted, Times type ParsedTestSteps = ReturnType['testSteps']; const STATUS_ICONS: Record = { - PASSED: 'PASS', + AMBIGUOUS: 'AMBIG', FAILED: 'FAIL', - SKIPPED: 'SKIP', + PASSED: 'PASS', PENDING: 'PEND', + SKIPPED: 'SKIP', UNDEFINED: 'UNDEF', - AMBIGUOUS: 'AMBIG', UNKNOWN: '?', }; -function timestampToMs(ts: Timestamp): number { - return (ts.seconds ?? 0) * 1000 + Math.round((ts.nanos ?? 0) / 1_000_000); +function timestampToMs(timestamp: Timestamp): number { + return (timestamp.seconds ?? 0) * 1000 + Math.round((timestamp.nanos ?? 0) / 1_000_000); } +/** + * Condensed Cucumber formatter intended for agent-readable test output. + * + * The formatter logs failed and warning scenarios with a compact summary, then + * emits aggregate scenario counts and duration at the end of the run. + */ export default class AgentFormatter extends Formatter { - static override readonly documentation = 'Condensed formatter for AI coding agents — minimal, token-efficient output.'; + /** Formatter documentation shown by Cucumber. */ + static override readonly documentation = 'Condensed formatter for AI coding agents: minimal, token-efficient output.'; private testRunStarted: TestRunStarted | undefined; private issueCount = 0; private scenarioCount = 0; private readonly statusCounts: Record = {}; + /** + * @param options Cucumber formatter options. + */ constructor(options: IFormatterOptions) { super(options); options.eventBroadcaster.on('envelope', (envelope: Envelope) => this.parseEnvelope(envelope)); @@ -48,20 +58,19 @@ export default class AgentFormatter extends Formatter { this.statusCounts[statusKey] = (this.statusCounts[statusKey] ?? 0) + 1; const parsed = formatterHelpers.parseTestCaseAttempt({ - testCaseAttempt: attempt, snippetBuilder: this.snippetBuilder, supportCodeLibrary: this.supportCodeLibrary, + testCaseAttempt: attempt, }); const icon = STATUS_ICONS[statusKey] ?? '?'; const { name, sourceLocation } = parsed.testCase; - const loc = sourceLocation ? `${sourceLocation.uri}:${sourceLocation.line}` : ''; - + const location = sourceLocation ? `${sourceLocation.uri}:${sourceLocation.line}` : ''; const isIssue = formatterHelpers.isFailure(attempt.worstTestStepResult, testCaseFinished.willBeRetried) || formatterHelpers.isWarning(attempt.worstTestStepResult, testCaseFinished.willBeRetried); if (isIssue) { this.issueCount++; - this.log(`[${icon}] ${name} (${loc})\n`); + this.log(`[${icon}] ${name} (${location})\n`); this.logFailedSteps(parsed.testSteps); } } @@ -69,7 +78,9 @@ export default class AgentFormatter extends Formatter { private logFailedSteps(testSteps: ParsedTestSteps): void { for (const step of testSteps) { const stepStatus = String(step.result.status); - if (stepStatus === 'PASSED' || stepStatus === 'SKIPPED') continue; + if (stepStatus === 'PASSED' || stepStatus === 'SKIPPED') { + continue; + } const stepIcon = STATUS_ICONS[stepStatus] ?? '?'; const stepText = step.text ?? step.keyword?.trim() ?? '(hook)'; @@ -95,10 +106,7 @@ export default class AgentFormatter extends Formatter { private onTestRunFinished(testRunFinished: TestRunFinished): void { this.log('\n--- (Agent) Results ---\n'); - const parts: string[] = []; - for (const [status, count] of Object.entries(this.statusCounts)) { - parts.push(`${status}: ${count}`); - } + const parts = Object.entries(this.statusCounts).map(([status, count]) => `${status}: ${count}`); this.log(`Scenarios: ${this.scenarioCount} (${parts.join(', ')})\n`); if (this.testRunStarted?.timestamp && testRunFinished.timestamp) { @@ -106,10 +114,6 @@ export default class AgentFormatter extends Formatter { this.log(`Duration: ${ms}ms\n`); } - if (this.issueCount === 0) { - this.log('All scenarios passed.\n'); - } else { - this.log(`Issues: ${this.issueCount}\n`); - } + this.log(this.issueCount === 0 ? 'All scenarios passed.\n' : `Issues: ${this.issueCount}\n`); } } diff --git a/packages/cellix/serenity-framework/src/index.ts b/packages/cellix/serenity-framework/src/index.ts new file mode 100644 index 000000000..c9cd12c6c --- /dev/null +++ b/packages/cellix/serenity-framework/src/index.ts @@ -0,0 +1,11 @@ +export type { GraphQLClientOptions, GraphQLResponse, GraphQLResponseError } from './clients/index.ts'; +export { GraphQLClient } from './clients/index.ts'; +export type { ActorNameResolutionOptions } from './cucumber/actor-name.ts'; +export { ActorName } from './cucumber/actor-name.ts'; +export { GherkinDataTable } from './cucumber/gherkin-data-table.ts'; +export type { ManagedSerenityWorldInfrastructure, ManagedSerenityWorldOptions } from './cucumber/world.ts'; +export { createManagedSerenityWorldClass, ManagedSerenityWorld, registerManagedSerenityWorld } from './cucumber/world.ts'; +export * from './pages/index.ts'; +export * from './serenity/index.ts'; +export * from './servers/index.ts'; +export * from './settings/index.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts b/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts new file mode 100644 index 000000000..fe0fb64a8 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/api-infrastructure.ts @@ -0,0 +1,244 @@ +import { MongoMemoryTestServer, type MongoMemoryTestServerOptions } from '../servers/mongo-memory-test-server.ts'; +import type { TestServer } from '../servers/test-server.ts'; + +/** Minimal connection shape needed for model cleanup. */ +export interface ManagedMongooseConnection { + /** Registered Mongoose model map. */ + models: Record; + + /** Delete a registered Mongoose model by name. */ + deleteModel(name: string): unknown; +} + +/** Minimal Mongoose service shape managed by API infrastructure. */ +export interface ManagedMongooseService { + /** Start the service. */ + startUp(): Promise | unknown; + + /** Stop the service. */ + shutDown(): Promise | unknown; + + /** Service internals exposing the active connection. */ + service: { + /** Active Mongoose connection. */ + connection: ManagedMongooseConnection; + }; +} + +/** Options used when API infrastructure owns a consumer-provided Mongoose service. */ +export interface ApiInfrastructureMongooseOptions { + /** Create the service from the framework-owned MongoDB connection string. */ + createService: (connectionString: string) => TMongooseService; + + /** Clear registered models after startup. Defaults to true. */ + clearModels?: boolean; +} + +/** Context supplied to the API server factory. */ +export interface ApiServerFactoryContext { + /** Framework-owned MongoDB server, or `undefined` when none is configured. */ + mongoServer: MongoMemoryTestServer | undefined; + + /** Resolve the MongoDB connection string. Throws if no `mongoServer` is configured. */ + getMongoConnectionString: () => string; + + /** Resolve the managed Mongoose service, when configured. */ + getMongooseService: () => TMongooseService; +} + +/** Factory that creates an API server after framework-owned resources are available. */ +export type ApiServerFactory = (context: ApiServerFactoryContext) => TestServer; + +/** Factory used to create the framework-owned MongoDB server. */ +export type ApiMongoServerFactory = (options: MongoMemoryTestServerOptions) => MongoMemoryTestServer; + +/** State exposed by {@link ApiInfrastructure}. */ +export interface ApiInfrastructureState { + /** Running MongoDB server for the suite. */ + mongoServer: MongoMemoryTestServer | undefined; + + /** Running API server for the suite. */ + apiServer: TestServer | undefined; + + /** Running Mongoose service, when configured. */ + mongooseService: TMongooseService | undefined; + + /** API endpoint URL, when the API server has started. */ + apiUrl: string | undefined; +} + +/** Options used by {@link ApiInfrastructure.using}. */ +export interface ApiInfrastructureOptions { + /** MongoDB memory server options. Omit when the suite does not need MongoDB. */ + mongoServer?: MongoMemoryTestServerOptions; + + /** Optional MongoDB server factory. Defaults to `new MongoMemoryTestServer(options)`. */ + createMongoServer?: ApiMongoServerFactory; + + /** Optional Mongoose service managed between MongoDB and the API server. */ + mongoose?: ApiInfrastructureMongooseOptions; + + /** Factory that creates the API server with access to framework-owned resources. */ + createApiServer: ApiServerFactory; +} + +/** + * Lifecycle manager for API acceptance tests. + * + * Use this when a suite needs only the always-present API acceptance base: + * MongoDB plus an API server. Consumers configure the concrete server objects + * with app-specific schema, context, services, seed data, and environment + * values before passing factories to the framework. + * + * @example + * ```ts + * export const infrastructure = ApiInfrastructure.using({ + * mongoServer: { dbName, port, replSetName, seedData }, + * createApiServer: ({ getMongooseService }) => new ApolloGraphQLTestServer({ ... }), + * }); + * ``` + */ +export class ApiInfrastructure { + private readonly mongoServer: MongoMemoryTestServer | undefined; + private apiServer: TestServer | undefined; + private apiUrl: string | undefined; + private mongooseService: TMongooseService | undefined; + private shutdownHandlersRegistered = false; + + private constructor(private readonly options: ApiInfrastructureOptions) { + this.mongoServer = options.mongoServer ? (options.createMongoServer ?? ((mongoOptions) => new MongoMemoryTestServer(mongoOptions)))(options.mongoServer) : undefined; + } + + /** + * Create an API acceptance infrastructure manager. + * + * @param options Required MongoDB options and API server factory. + */ + static using(options: ApiInfrastructureOptions): ApiInfrastructure { + return new ApiInfrastructure(options); + } + + /** Start MongoDB and the API server if they are not already running. */ + async ensureStarted(): Promise { + if (this.apiServer?.isRunning()) { + return; + } + + try { + if (this.mongoServer && !this.mongoServer.isRunning()) { + await this.mongoServer.start(); + } + + await this.ensureMongooseService(); + const apiServer = this.ensureApiServer(); + await apiServer.start(); + this.apiUrl = apiServer.getUrl(); + } catch (error) { + await this.stopAll(); + throw error; + } + } + + /** Reset MongoDB between scenarios without restarting the API server. */ + async resetScenarioState(): Promise { + if (this.mongoServer?.isRunning()) { + await this.mongoServer.resetForScenario(); + } + } + + /** Stop the API server and MongoDB, swallowing shutdown errors from already-failed resources. */ + async stopAll(): Promise { + await this.apiServer?.stop().catch(() => undefined); + this.apiServer = undefined; + + if (this.mongooseService) { + await Promise.resolve(this.mongooseService.shutDown()).catch(() => undefined); + this.mongooseService = undefined; + } + + await this.mongoServer?.stop().catch(() => undefined); + + this.apiUrl = undefined; + } + + /** Return the current infrastructure state. */ + getState(): ApiInfrastructureState { + return { + apiUrl: this.apiUrl, + apiServer: this.apiServer, + mongooseService: this.mongooseService, + mongoServer: this.mongoServer, + }; + } + + /** Register SIGINT and SIGTERM handlers that stop infrastructure before exiting. */ + registerProcessShutdownHandlers(): this { + if (this.shutdownHandlersRegistered) { + return this; + } + + this.shutdownHandlersRegistered = true; + const shutdown = (signal: NodeJS.Signals) => { + void this.stopAll().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + return this; + } + + private ensureApiServer(): TestServer { + const { mongoServer } = this; + this.apiServer ??= this.options.createApiServer({ + getMongoConnectionString: mongoServer + ? () => mongoServer.getConnectionString() + : () => { + throw new Error('ApiInfrastructure: no mongoServer configured'); + }, + getMongooseService: () => this.getMongooseService(), + mongoServer, + }); + + return this.apiServer; + } + + private async ensureMongooseService(): Promise { + if (!this.options.mongoose) { + return undefined; + } + + if (!this.mongoServer) { + throw new Error('ApiInfrastructure: mongoose option requires mongoServer to be configured'); + } + + if (!this.mongooseService) { + this.mongooseService = this.options.mongoose.createService(this.mongoServer.getConnectionString()); + await this.mongooseService.startUp(); + if (this.options.mongoose.clearModels ?? true) { + this.clearMongooseModels(this.mongooseService); + } + } + + return this.mongooseService; + } + + private getMongooseService(): TMongooseService { + if (!this.mongooseService) { + throw new Error('ApiInfrastructure Mongoose service is not configured or has not started'); + } + + return this.mongooseService; + } + + private clearMongooseModels(mongooseService: ManagedMongooseService): void { + for (const modelName of Object.keys(mongooseService.service.connection.models)) { + try { + mongooseService.service.connection.deleteModel(modelName); + } catch { + /* already deleted */ + } + } + } +} diff --git a/packages/cellix/serenity-framework/src/infrastructure/api.ts b/packages/cellix/serenity-framework/src/infrastructure/api.ts new file mode 100644 index 000000000..d4cc0bb9b --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/api.ts @@ -0,0 +1,11 @@ +export type { + ApiInfrastructureMongooseOptions, + ApiInfrastructureOptions, + ApiInfrastructureState, + ApiMongoServerFactory, + ApiServerFactory, + ApiServerFactoryContext, + ManagedMongooseConnection, + ManagedMongooseService, +} from './api-infrastructure.ts'; +export { ApiInfrastructure } from './api-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts b/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts new file mode 100644 index 000000000..907937c8e --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/e2e-infrastructure.ts @@ -0,0 +1,308 @@ +import { type Browser, type BrowserContext, type BrowserContextOptions, chromium } from 'playwright'; +import { BrowseTheWeb } from '../serenity/browse-the-web.ts'; +import { MongoMemoryTestServer, type MongoMemoryTestServerOptions } from '../servers/mongo-memory-test-server.ts'; +import type { TestServer } from '../servers/test-server.ts'; + +/** UI portal registration used by {@link E2EInfrastructure}. */ +export interface UiPortalRegistration { + /** Stable logical portal name, such as `community` or `staff`. */ + name: TName; + + /** Server that exposes the portal. */ + server: TestServer; +} + +/** State exposed by {@link E2EInfrastructure}. */ +export interface E2EInfrastructureState { + /** Running MongoDB server. */ + mongoServer: MongoMemoryTestServer | undefined; + + /** Running Azurite server. */ + azuriteServer: TestServer | undefined; + + /** Running auth server. */ + authServer: TestServer | undefined; + + /** Running API server. */ + apiServer: TestServer | undefined; + + /** API URL resolved from the API server. */ + apiUrl: string | undefined; + + /** Browser launched for the suite. */ + browser: Browser | undefined; + + /** Browser ability assigned to Serenity actors. */ + browseTheWeb: BrowseTheWeb | undefined; + + /** Base URLs for every registered UI portal. */ + uiPortalBaseUrls: Readonly>; +} + +/** Context supplied to E2E infrastructure callbacks. */ +export interface E2EInfrastructureCallbackContext { + /** Running MongoDB server, or `undefined` when none is configured. */ + mongoServer: MongoMemoryTestServer | undefined; + + /** Running Azurite server. */ + azuriteServer: TestServer; + + /** Running auth server. */ + authServer: TestServer; + + /** Running API server, when phase two has started. */ + apiServer?: TestServer; + + /** Resolve the MongoDB connection string after MongoDB has started. */ + getMongoConnectionString: () => string; +} + +/** Factory that creates an API server once framework-owned MongoDB exists. */ +export type E2EApiServerFactory = (context: E2EInfrastructureCallbackContext) => TestServer; + +/** Factory used to create the framework-owned MongoDB server. */ +export type E2EMongoServerFactory = (options: MongoMemoryTestServerOptions) => MongoMemoryTestServer; + +/** Options used by {@link E2EInfrastructure.using}. */ +export interface E2EInfrastructureOptions { + /** MongoDB memory server options. Omit when the suite does not need MongoDB. */ + mongoServer?: MongoMemoryTestServerOptions; + + /** Optional MongoDB server factory. Defaults to `new MongoMemoryTestServer(options)`. */ + createMongoServer?: E2EMongoServerFactory; + + /** Azurite server required by the suite. */ + azuriteServer: TestServer; + + /** Auth server required by the suite. */ + authServer: TestServer; + + /** Factory that creates the API server with access to framework-owned MongoDB. */ + createApiServer: E2EApiServerFactory; + + /** Suite environment setup, such as starting a local proxy. */ + setupEnvironment?: () => Promise | void; + + /** Suite environment cleanup. */ + cleanupEnvironment?: () => Promise | void; + + /** Launch the browser in headless mode. Defaults to `true`. */ + headless?: boolean; + + /** Browser context options for the authenticated context. */ + browserContextOptions?: BrowserContextOptions | ((state: E2EInfrastructureState) => BrowserContextOptions); +} + +/** + * Lifecycle manager for browser E2E test suites. + * + * The manager requires the base servers that every full-system verification + * suite needs: MongoDB, Azurite, auth, API, and at least one UI portal. Extra + * portals can be chained with {@link addUiPortal}, matching Cellix application + * startup APIs where the invariant pieces are required up front and optional + * pieces are registered fluently. + * + * @example + * ```ts + * export const infrastructure = E2EInfrastructure + * .using({ + * mongoServer: { dbName, port, replSetName, seedData }, + * azuriteServer, + * authServer, + * createApiServer: ({ getMongoConnectionString }) => createApiServer(getMongoConnectionString), + * }) + * .addUiPortal('community', communityPortal) + * .addUiPortal('staff', staffPortal); + * ``` + */ +export class E2EInfrastructure { + private readonly uiPortals = new Map(); + private readonly mongoServer: MongoMemoryTestServer | undefined; + private apiServer: TestServer | undefined; + private environmentReady = false; + private apiUrl: string | undefined; + private browser: Browser | undefined; + private browserContext: BrowserContext | undefined; + private browseTheWeb: BrowseTheWeb | undefined; + private shutdownHandlersRegistered = false; + + private constructor(private readonly options: E2EInfrastructureOptions) { + this.mongoServer = options.mongoServer ? (options.createMongoServer ?? ((mongoOptions) => new MongoMemoryTestServer(mongoOptions)))(options.mongoServer) : undefined; + } + + /** + * Create a browser E2E infrastructure manager. + * + * @param options Required base servers and browser setup. + */ + static using(options: E2EInfrastructureOptions): E2EInfrastructure { + return new E2EInfrastructure(options); + } + + /** + * Register another UI portal server. + * + * @param name Stable logical portal name. + * @param server Portal server. + */ + addUiPortal(name: string, server: TestServer): this { + if (this.uiPortals.has(name)) { + throw new Error(`UI portal '${name}' is already registered`); + } + + this.uiPortals.set(name, server); + return this; + } + + /** Start the environment, base servers, UI portals, browser, and browser ability. */ + async ensureStarted(): Promise { + if (this.uiPortals.size === 0) { + throw new Error('E2EInfrastructure requires at least one UI portal'); + } + + try { + await this.ensureEnvironment(); + await this.startFoundationServers(); + await this.startApplicationServers(); + await this.ensureBrowserAbility(); + } catch (error) { + await this.stopAll(); + throw error; + } + } + + /** Reset mutable scenario state without restarting servers or the browser. */ + async resetScenarioState(): Promise { + if (this.mongoServer?.isRunning()) { + await this.mongoServer.resetForScenario(); + } + } + + /** Stop browser resources, UI portals, base servers, and suite environment. */ + async stopAll(): Promise { + if (this.browseTheWeb) { + await this.browseTheWeb.close().catch(() => undefined); + this.browseTheWeb = undefined; + this.browserContext = undefined; + } else if (this.browserContext) { + await this.browserContext.close().catch(() => undefined); + this.browserContext = undefined; + } + + if (this.browser) { + await this.browser.close().catch(() => undefined); + this.browser = undefined; + } + + await Promise.all([...this.uiPortals.values()].reverse().map((server) => server.stop().catch(() => undefined))); + await this.apiServer?.stop().catch(() => undefined); + await this.options.authServer.stop().catch(() => undefined); + await this.mongoServer?.stop().catch(() => undefined); + await this.options.azuriteServer.stop().catch(() => undefined); + + this.apiUrl = undefined; + this.apiServer = undefined; + + if (this.environmentReady) { + await this.options.cleanupEnvironment?.(); + this.environmentReady = false; + } + } + + /** Return the current infrastructure state. */ + getState(): E2EInfrastructureState { + const uiPortalBaseUrls = Object.fromEntries([...this.uiPortals].map(([name, server]) => [name, server.getUrl()])); + + return { + apiServer: this.apiServer, + apiUrl: this.apiUrl, + authServer: this.options.authServer, + browseTheWeb: this.browseTheWeb, + browser: this.browser, + mongoServer: this.mongoServer, + azuriteServer: this.options.azuriteServer, + uiPortalBaseUrls, + }; + } + + /** Register SIGINT and SIGTERM handlers that stop infrastructure before exiting. */ + registerProcessShutdownHandlers(): this { + if (this.shutdownHandlersRegistered) { + return this; + } + + this.shutdownHandlersRegistered = true; + const shutdown = (signal: NodeJS.Signals) => { + void this.stopAll().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + return this; + } + + private async ensureEnvironment(): Promise { + if (this.environmentReady) { + return; + } + + await this.options.setupEnvironment?.(); + this.environmentReady = true; + } + + private async startFoundationServers(): Promise { + await Promise.all([ + this.mongoServer && !this.mongoServer.isRunning() ? this.mongoServer.start() : undefined, + this.options.azuriteServer.isRunning() ? undefined : this.options.azuriteServer.start(), + this.options.authServer.isRunning() ? undefined : this.options.authServer.start(), + ]); + } + + private async startApplicationServers(): Promise { + const apiServer = this.ensureApiServer(); + await Promise.all([apiServer.isRunning() ? undefined : apiServer.start(), ...[...this.uiPortals.values()].map((server) => (server.isRunning() ? undefined : server.start()))]); + + this.apiUrl = apiServer.getUrl(); + } + + private ensureApiServer(): TestServer { + const { mongoServer } = this; + this.apiServer ??= this.options.createApiServer({ + authServer: this.options.authServer, + azuriteServer: this.options.azuriteServer, + getMongoConnectionString: mongoServer + ? () => mongoServer.getConnectionString() + : () => { + throw new Error('E2EInfrastructure: no mongoServer configured'); + }, + mongoServer, + }); + + return this.apiServer; + } + + private async ensureBrowserAbility(): Promise { + if (!this.browser) { + this.browser = await chromium.launch({ headless: this.options.headless ?? true }); + } + + if (this.browseTheWeb) { + return; + } + + const state = this.getState(); + const contextOptions = typeof this.options.browserContextOptions === 'function' ? this.options.browserContextOptions(state) : this.options.browserContextOptions; + this.browserContext = await this.browser.newContext(contextOptions); + const page = await this.browserContext.newPage(); + + try { + this.browseTheWeb = BrowseTheWeb.using(page, this.browserContext); + } catch (error) { + await this.browserContext.close().catch(() => undefined); + this.browserContext = undefined; + throw error; + } + } +} diff --git a/packages/cellix/serenity-framework/src/infrastructure/e2e.ts b/packages/cellix/serenity-framework/src/infrastructure/e2e.ts new file mode 100644 index 000000000..3e93f3921 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/e2e.ts @@ -0,0 +1,9 @@ +export type { + E2EApiServerFactory, + E2EInfrastructureCallbackContext, + E2EInfrastructureOptions, + E2EInfrastructureState, + E2EMongoServerFactory, + UiPortalRegistration, +} from './e2e-infrastructure.ts'; +export { E2EInfrastructure } from './e2e-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/index.ts b/packages/cellix/serenity-framework/src/infrastructure/index.ts new file mode 100644 index 000000000..5c4ee90c3 --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/index.ts @@ -0,0 +1,4 @@ +export type { ApiInfrastructureOptions, ApiInfrastructureState } from './api.ts'; +export { ApiInfrastructure } from './api.ts'; +export type { E2EInfrastructureCallbackContext, E2EInfrastructureOptions, E2EInfrastructureState, UiPortalRegistration } from './e2e.ts'; +export { E2EInfrastructure } from './e2e.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts b/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts new file mode 100644 index 000000000..ad428015e --- /dev/null +++ b/packages/cellix/serenity-framework/src/infrastructure/infrastructure.test.ts @@ -0,0 +1,158 @@ +import { type Browser, type BrowserContext, chromium, type Page } from 'playwright'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('playwright', () => ({ + chromium: { launch: vi.fn() }, +})); + +import type { MongoMemoryTestServer } from '../servers/mongo-memory-test-server.ts'; +import type { TestServer } from '../servers/test-server.ts'; +import { ApiInfrastructure, E2EInfrastructure } from './index.ts'; + +class FakeServer implements TestServer { + startCalls = 0; + stopCalls = 0; + resetCalls = 0; + private running = false; + + constructor(private readonly url: string) {} + + start(): Promise { + this.startCalls += 1; + this.running = true; + return Promise.resolve(); + } + + stop(): Promise { + this.stopCalls += 1; + this.running = false; + return Promise.resolve(); + } + + isRunning(): boolean { + return this.running; + } + + getUrl(): string { + return this.url; + } + + resetForScenario(): Promise { + this.resetCalls += 1; + return Promise.resolve(); + } +} + +function mongoServer(url = 'mongodb://test'): FakeServer & MongoMemoryTestServer { + return new FakeServer(url) as FakeServer & MongoMemoryTestServer; +} + +function mongoOptions() { + return { + dbName: 'test', + port: 27_017, + replSetName: 'rs0', + }; +} + +function browserStubs() { + const page = { + close: vi.fn(), + isClosed: vi.fn(() => false), + } as unknown as Page; + const context = { + close: vi.fn(), + newPage: vi.fn(async () => page), + } as unknown as BrowserContext; + const browser = { + close: vi.fn(), + newContext: vi.fn(async () => context), + } as unknown as Browser; + + return { browser, context, page }; +} + +describe('ApiInfrastructure', () => { + it('starts MongoDB before the API server and exposes the API URL', async () => { + const mongo = mongoServer(); + const graphQL = new FakeServer('http://127.0.0.1:4000/graphql'); + + const infrastructure = ApiInfrastructure.using({ + createApiServer: () => graphQL, + createMongoServer: () => mongo, + mongoServer: mongoOptions(), + }); + + await infrastructure.ensureStarted(); + + expect(infrastructure.getState().apiUrl).toBe('http://127.0.0.1:4000/graphql'); + expect(graphQL.startCalls).toBe(1); + }); + + it('resets MongoDB without restarting the GraphQL server between scenarios', async () => { + const mongo = mongoServer(); + const graphQL = new FakeServer('http://127.0.0.1:4000/graphql'); + const infrastructure = ApiInfrastructure.using({ + createApiServer: () => graphQL, + createMongoServer: () => mongo, + mongoServer: mongoOptions(), + }); + + await infrastructure.ensureStarted(); + await infrastructure.resetScenarioState(); + await infrastructure.ensureStarted(); + + expect(mongo.resetCalls).toBe(1); + expect(graphQL.startCalls).toBe(1); + }); +}); + +describe('E2EInfrastructure', () => { + it('requires at least one chained UI portal and exposes all portal URLs', async () => { + const mongo = mongoServer(); + const azurite = new FakeServer('http://127.0.0.1:10000'); + const auth = new FakeServer('https://auth.test'); + const api = new FakeServer('https://api.test/api/graphql'); + const community = new FakeServer('https://community.test'); + const staff = new FakeServer('https://staff.test'); + const { browser } = browserStubs(); + vi.mocked(chromium.launch).mockResolvedValue(browser); + + const infrastructure = E2EInfrastructure.using({ + authServer: auth, + azuriteServer: azurite, + createApiServer: () => api, + createMongoServer: () => mongo, + mongoServer: mongoOptions(), + }) + .addUiPortal('community', community) + .addUiPortal('staff', staff); + + await infrastructure.ensureStarted(); + + expect(infrastructure.getState().uiPortalBaseUrls).toEqual({ + community: 'https://community.test', + staff: 'https://staff.test', + }); + expect(api.startCalls).toBe(1); + expect(staff.startCalls).toBe(1); + }); + + it('creates the browser ability without owning app login behavior', async () => { + const mongo = mongoServer(); + const { browser } = browserStubs(); + vi.mocked(chromium.launch).mockResolvedValue(browser); + + const infrastructure = E2EInfrastructure.using({ + authServer: new FakeServer('https://auth.test'), + azuriteServer: new FakeServer('http://127.0.0.1:10000'), + createApiServer: () => new FakeServer('https://api.test/api/graphql'), + createMongoServer: () => mongo, + mongoServer: mongoOptions(), + }).addUiPortal('community', new FakeServer('https://community.test')); + + await infrastructure.ensureStarted(); + + expect(infrastructure.getState().browseTheWeb).toBeDefined(); + }); +}); diff --git a/packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts b/packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts new file mode 100644 index 000000000..eab193590 --- /dev/null +++ b/packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts @@ -0,0 +1,54 @@ +/** + * ESM loader hooks that intercept CSS, image, and other non-JS imports so + * they resolve to empty modules instead of throwing in Node.js. + * + * Usage: `NODE_OPTIONS='--import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js` + */ + +const ASSET_RE = /\.(css|less|scss|sass|svg|png|jpe?g|gif|webp|woff2?|ttf|eot|ico)$/i; + +/** Minimal loader context needed by the asset hook. */ +export interface AssetLoaderResolveContext { + /** URL of the module importing the asset, supplied by Node's loader API. */ + parentURL?: string; +} + +/** Result returned by Node's ESM resolve hook. */ +export interface AssetLoaderResolveResult { + /** Whether the loader chain should stop at this result. */ + shortCircuit?: boolean; + + /** Resolved module URL. */ + url: string; +} + +/** Next resolver supplied by Node's ESM loader chain. */ +export type NextAssetLoaderResolve = (specifier: string, context: AssetLoaderResolveContext) => Promise; + +/** + * Resolve CSS, image, font, and Ant Design ESM imports for jsdom acceptance tests. + * + * Asset imports resolve to empty JavaScript modules. Ant Design `antd/es/*` + * imports are redirected to `antd/lib/*` when possible because many Node-based + * component tests execute through CommonJS-compatible package output. + */ +export async function resolve(specifier: string, context: AssetLoaderResolveContext, nextResolve: NextAssetLoaderResolve): Promise { + if (ASSET_RE.test(specifier)) { + return { + shortCircuit: true, + url: `data:text/javascript,export default ''`, + }; + } + + // Redirect antd/es/* to antd/lib/* for CJS/ESM compatibility in Node.js + if (specifier.includes('antd/es/')) { + const redirected = specifier.replace('antd/es/', 'antd/lib/'); + try { + return await nextResolve(redirected, context); + } catch { + // fall through to default + } + } + + return nextResolve(specifier, context); +} diff --git a/packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts b/packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts new file mode 100644 index 000000000..6b7d0666d --- /dev/null +++ b/packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record; + export default classes; +} diff --git a/packages/cellix/serenity-framework/src/jsdom/css-modules.ts b/packages/cellix/serenity-framework/src/jsdom/css-modules.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/cellix/serenity-framework/src/jsdom/css-modules.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom.d.ts b/packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts similarity index 100% rename from packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom.d.ts rename to packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts diff --git a/packages/cellix/serenity-framework/src/jsdom/react-render.ts b/packages/cellix/serenity-framework/src/jsdom/react-render.ts new file mode 100644 index 000000000..fb1065fe2 --- /dev/null +++ b/packages/cellix/serenity-framework/src/jsdom/react-render.ts @@ -0,0 +1,47 @@ +import { type RenderResult, render } from '@testing-library/react'; +import type React from 'react'; + +/** Wraps a rendered React element before it is mounted. */ +export type ReactRenderWrapper = (children: React.ReactElement) => React.ReactElement; + +/** Options used by {@link mountComponent}. */ +export interface ReactMountOptions { + /** Optional wrapper used for providers such as routing, theme, or GraphQL. */ + wrapper?: ReactRenderWrapper; +} + +let rendered: RenderResult | null = null; + +/** + * Mount a React element into the active jsdom document. + * + * Any previously mounted element is unmounted first so component-level + * acceptance tests do not leak state between scenarios. + * + * @param ui React element to mount. + * @param options Optional provider wrapper. + * @returns Testing Library render result for the mounted component. + */ +export function mountComponent(ui: React.ReactElement, options?: ReactMountOptions): RenderResult { + unmountComponent(); + + rendered = render(options?.wrapper ? options.wrapper(ui) : ui); + return rendered; +} + +/** + * Unmount the currently mounted component, when one exists. + */ +export function unmountComponent(): void { + if (rendered) { + rendered.unmount(); + rendered = null; + } +} + +/** + * Return the current Testing Library render result. + */ +export function getRendered(): RenderResult | null { + return rendered; +} diff --git a/packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts b/packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts new file mode 100644 index 000000000..d13d50cd8 --- /dev/null +++ b/packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts @@ -0,0 +1,10 @@ +/** + * Registers the asset-loader ESM hooks so CSS/image imports resolve + * without errors in Node.js. + * + * Use via NODE_OPTIONS: + * `NODE_OPTIONS='--import @cellix/serenity-framework/jsdom/register-asset-loader'`. + */ +import { register } from 'node:module'; + +register(new URL('./asset-loader-hooks.js', import.meta.url).href, import.meta.url); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts b/packages/cellix/serenity-framework/src/jsdom/setup.ts similarity index 100% rename from packages/ocom-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts rename to packages/cellix/serenity-framework/src/jsdom/setup.ts diff --git a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts similarity index 57% rename from packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts rename to packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts index 59c5cf3e1..c82c7c412 100644 --- a/packages/ocom-verification/verification-shared/src/pages/adapters/jsdom-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts @@ -1,5 +1,5 @@ import { act, fireEvent } from '@testing-library/react'; -import type { ElementHandle, PageAdapter, PageNavigationWaitUntil, PageUrlMatcher } from '../page-adapter.ts'; +import type { ElementHandle, ElementWaitOptions, PageAdapter, PageNavigationOptions, PageUrlMatcher } from '../page-adapter.ts'; function getGlobalDocument(container: Element): Document { return container.ownerDocument ?? document; @@ -12,8 +12,7 @@ function findLabelControl(container: Element, text: string): Element | null { if (matchingLabel) { const forId = matchingLabel.getAttribute('for'); if (forId) { - const doc = getGlobalDocument(container); - return doc.getElementById(forId); + return getGlobalDocument(container).getElementById(forId); } const wrappedControl = matchingLabel.querySelector('input, textarea, select, [role="textbox"], [role="combobox"], [role="checkbox"]'); @@ -22,21 +21,24 @@ function findLabelControl(container: Element, text: string): Element | null { } } - const ariaMatch = container.querySelector(`[aria-label="${text}"], [aria-label*="${text}"]`); - return ariaMatch; + return container.querySelector(`[aria-label="${text}"], [aria-label*="${text}"]`); } -class JsdomElementHandle implements ElementHandle { - constructor(private readonly el: Element | null) {} +/** + * Element handle backed by a jsdom `Element`. + */ +export class JsdomElementHandle implements ElementHandle { + /** + * @param element Element to adapt, or `null` for a missing selection. + */ + constructor(private readonly element: Element | null) {} fill(value: string): Promise { - if (!this.el) return Promise.resolve(); - - if (!(this.el instanceof HTMLInputElement || this.el instanceof HTMLTextAreaElement)) { + if (!(this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement)) { return Promise.resolve(); } - const input = this.el; + const input = this.element; const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; act(() => { @@ -51,70 +53,75 @@ class JsdomElementHandle implements ElementHandle { fireEvent.input(input, { target: { value } }); fireEvent.change(input, { target: { value } }); }); + return Promise.resolve(); } click(): Promise { - if (this.el) { - const el = this.el; + if (this.element) { + const element = this.element; act(() => { - fireEvent.click(el); + fireEvent.click(element); }); } return Promise.resolve(); } check(): Promise { - if (this.el instanceof HTMLInputElement) { - const el = this.el; + if (this.element instanceof HTMLInputElement) { + const element = this.element; act(() => { - fireEvent.click(el, { target: { checked: true } }); + fireEvent.click(element, { target: { checked: true } }); }); return Promise.resolve(); } - if (this.el) { - const el = this.el; - act(() => { - fireEvent.click(el); - }); - } - return Promise.resolve(); + return this.click(); } textContent(): Promise { - return Promise.resolve(this.el?.textContent ?? null); + return Promise.resolve(this.element?.textContent ?? null); } getAttribute(name: string): Promise { - return Promise.resolve(this.el?.getAttribute(name) ?? null); + return Promise.resolve(this.element?.getAttribute(name) ?? null); } isVisible(): Promise { - return Promise.resolve(this.el !== null); + return Promise.resolve(this.element !== null); } - waitFor(_options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + waitFor(_options?: ElementWaitOptions): Promise { return Promise.resolve(); } querySelector(selector: string): Promise { - const child = this.el?.querySelector(selector) ?? null; + const child = this.element?.querySelector(selector) ?? null; return Promise.resolve(child ? new JsdomElementHandle(child) : null); } querySelectorAll(selector: string): Promise { - if (!this.el) return Promise.resolve([]); - return Promise.resolve(Array.from(this.el.querySelectorAll(selector)).map((el) => new JsdomElementHandle(el))); + if (!this.element) { + return Promise.resolve([]); + } + return Promise.resolve(Array.from(this.element.querySelectorAll(selector)).map((element) => new JsdomElementHandle(element))); } } +/** + * Page adapter backed by a jsdom container element. + * + * Use this adapter in component-level Cucumber tests that render React into + * jsdom while reusing the same page-object classes used by browser E2E tests. + */ export class JsdomPageAdapter implements PageAdapter { + /** + * @param container Root element that scopes all selections for this page. + */ constructor(private readonly container: Element) {} getByPlaceholder(text: string): ElementHandle { - const el = this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`); - return new JsdomElementHandle(el); + return new JsdomElementHandle(this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`)); } getByLabel(text: string): ElementHandle { @@ -123,31 +130,29 @@ export class JsdomPageAdapter implements PageAdapter { getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { const candidates = Array.from(this.container.querySelectorAll(`[role="${role}"], ${role}`)); - const semanticMap: Record = { button: 'button', - textbox: 'input[type="text"], input:not([type]), textarea', - combobox: 'select, [role="combobox"]', checkbox: 'input[type="checkbox"], [role="checkbox"]', + combobox: 'select, [role="combobox"]', table: 'table', + textbox: 'input[type="text"], input:not([type]), textarea', }; const semanticSelector = semanticMap[role]; + if (semanticSelector) { - const semantic = Array.from(this.container.querySelectorAll(semanticSelector)); - for (const el of semantic) { - if (!candidates.includes(el)) candidates.push(el); + for (const element of Array.from(this.container.querySelectorAll(semanticSelector))) { + if (!candidates.includes(element)) { + candidates.push(element); + } } } const nameFilter = options?.name; if (nameFilter) { - const match = candidates.find((el) => { - const text = el.textContent ?? ''; - const ariaLabel = el.getAttribute('aria-label') ?? ''; - if (nameFilter instanceof RegExp) { - return nameFilter.test(text) || nameFilter.test(ariaLabel); - } - return text.includes(nameFilter) || ariaLabel.includes(nameFilter); + const match = candidates.find((element) => { + const textContent = element.textContent ?? ''; + const ariaLabel = element.getAttribute('aria-label') ?? ''; + return nameFilter instanceof RegExp ? nameFilter.test(textContent) || nameFilter.test(ariaLabel) : textContent.includes(nameFilter) || ariaLabel.includes(nameFilter); }); return new JsdomElementHandle(match ?? null); } @@ -156,19 +161,18 @@ export class JsdomPageAdapter implements PageAdapter { } locator(selector: string): ElementHandle { - const el = this.container.querySelector(selector); - return new JsdomElementHandle(el); + return new JsdomElementHandle(this.container.querySelector(selector)); } locatorAll(selector: string): Promise { - return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((el) => new JsdomElementHandle(el))); + return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((element) => new JsdomElementHandle(element))); } getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle { const scope = options?.selector ? (this.container.querySelector(options.selector) ?? this.container) : this.container; const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); let node: Node | null; - // biome-ignore lint/suspicious/noAssignInExpressions: walker pattern + // biome-ignore lint/suspicious/noAssignInExpressions: tree walkers expose the next node through assignment. while ((node = walker.nextNode())) { const content = node.textContent ?? ''; const matches = text instanceof RegExp ? text.test(content) : content.includes(text); @@ -179,22 +183,17 @@ export class JsdomPageAdapter implements PageAdapter { return new JsdomElementHandle(null); } - goto(url: string, _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { - if (typeof globalThis !== 'undefined') { - globalThis.history.pushState({}, '', url); - } + goto(url: string, _options?: PageNavigationOptions): Promise { + globalThis.history?.pushState({}, '', url); return Promise.resolve(); } - waitForURL(_url: PageUrlMatcher, _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + waitForURL(_url: PageUrlMatcher, _options?: PageNavigationOptions): Promise { return Promise.resolve(); } url(): string { - if (typeof globalThis !== 'undefined') { - return globalThis.location.href; - } - return 'about:blank'; + return globalThis.location?.href ?? 'about:blank'; } waitForTimeout(_timeout: number): Promise { diff --git a/packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts similarity index 76% rename from packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts rename to packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts index fbb071706..8c4563fa6 100644 --- a/packages/ocom-verification/verification-shared/src/pages/adapters/playwright-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts @@ -1,7 +1,13 @@ import type { Locator as PlaywrightLocator, Page as PlaywrightPage } from 'playwright'; -import type { ElementHandle, PageAdapter, PageNavigationWaitUntil, PageUrlMatcher } from '../page-adapter.ts'; - -class PlaywrightElementHandle implements ElementHandle { +import type { ElementHandle, ElementWaitOptions, PageAdapter, PageNavigationOptions, PageUrlMatcher } from '../page-adapter.ts'; + +/** + * Element handle backed by a Playwright `Locator`. + */ +export class PlaywrightElementHandle implements ElementHandle { + /** + * @param locator Playwright locator to adapt to the framework element contract. + */ constructor(private readonly locator: PlaywrightLocator) {} async fill(value: string): Promise { @@ -28,7 +34,7 @@ class PlaywrightElementHandle implements ElementHandle { return this.locator.isVisible(); } - async waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + async waitFor(options?: ElementWaitOptions): Promise { await this.locator.waitFor(options); } @@ -49,7 +55,16 @@ class PlaywrightElementHandle implements ElementHandle { } } +/** + * Page adapter backed by a Playwright `Page`. + * + * Use this adapter at the edge of an E2E test package, then pass it into + * app-specific page objects that depend only on {@link PageAdapter}. + */ export class PlaywrightPageAdapter implements PageAdapter { + /** + * @param page Playwright page used to resolve locators and navigation. + */ constructor(private readonly page: PlaywrightPage) {} getByPlaceholder(text: string): ElementHandle { @@ -84,11 +99,11 @@ export class PlaywrightPageAdapter implements PageAdapter { return new PlaywrightElementHandle(root.getByText(text).first()); } - async goto(url: string, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + async goto(url: string, options?: PageNavigationOptions): Promise { await this.page.goto(url, options); } - async waitForURL(url: PageUrlMatcher, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise { + async waitForURL(url: PageUrlMatcher, options?: PageNavigationOptions): Promise { await this.page.waitForURL(url as Parameters[0], options); } diff --git a/packages/cellix/serenity-framework/src/pages/index.ts b/packages/cellix/serenity-framework/src/pages/index.ts new file mode 100644 index 000000000..2daf84b4e --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/index.ts @@ -0,0 +1,12 @@ +export type { + ElementHandle, + ElementWaitOptions, + ElementWaitState, + PageAdapter, + PageAdapterMode, + PageNavigationOptions, + PageNavigationWaitUntil, + PageUrlMatcher, +} from './page-adapter.ts'; +export type { PageObject } from './page-object.ts'; +export { AdapterBackedPageObject } from './page-object.ts'; diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts new file mode 100644 index 000000000..0889f07fb --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -0,0 +1,103 @@ +/** + * Cross-runtime handle for a single element selected by a {@link PageAdapter}. + * + * Implementations may wrap a Playwright locator, a jsdom element, or another + * browser automation primitive. Page objects should use this interface instead + * of depending on a concrete runtime. + */ +export interface ElementHandle { + /** Fill an editable control with the supplied value. */ + fill(value: string): Promise; + + /** Click the element. */ + click(): Promise; + + /** Check a checkbox-like control. */ + check(): Promise; + + /** Read the element text content, or `null` when no element is available. */ + textContent(): Promise; + + /** Read an element attribute, or `null` when the attribute is missing. */ + getAttribute(name: string): Promise; + + /** Return whether the element is currently visible to the adapter runtime. */ + isVisible(): Promise; + + /** Wait for the element to enter a runtime-supported state. */ + waitFor(options?: ElementWaitOptions): Promise; + + /** Find the first child matching a CSS selector. */ + querySelector(selector: string): Promise; + + /** Find all children matching a CSS selector. */ + querySelectorAll(selector: string): Promise; +} + +/** Wait states supported by cross-runtime element handles. */ +export type ElementWaitState = 'visible' | 'hidden' | 'attached' | 'detached'; + +/** Options used when waiting for an element state. */ +export interface ElementWaitOptions { + /** Runtime-specific element state to wait for. */ + state?: ElementWaitState; + + /** Maximum wait time in milliseconds. */ + timeout?: number; +} + +/** Navigation lifecycle values shared with Playwright-compatible adapters. */ +export type PageNavigationWaitUntil = 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; + +/** URL matcher accepted by cross-runtime page adapters. */ +export type PageUrlMatcher = string | RegExp | ((url: URL) => boolean); + +/** Options used when navigating or waiting for a URL. */ +export interface PageNavigationOptions { + /** Maximum wait time in milliseconds. */ + timeout?: number; + + /** Navigation lifecycle state to wait for. */ + waitUntil?: PageNavigationWaitUntil; +} + +/** + * Runtime-neutral page API for app-specific page objects. + * + * Page objects consume this interface so the same page-object class can run in + * fast jsdom acceptance tests and full Playwright E2E tests. + */ +export interface PageAdapter { + /** Select an element by placeholder text. */ + getByPlaceholder(text: string): ElementHandle; + + /** Select a form control by visible or ARIA label text. */ + getByLabel(text: string): ElementHandle; + + /** Select an element by accessible role and optional accessible name. */ + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; + + /** Select the first element matching a CSS selector. */ + locator(selector: string): ElementHandle; + + /** Select all elements matching a CSS selector. */ + locatorAll(selector: string): Promise; + + /** Select the first element containing text, optionally scoped by selector. */ + getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle; + + /** Navigate the current page-like runtime to a URL. */ + goto(url: string, options?: PageNavigationOptions): Promise; + + /** Wait for the current URL to match a string, regex, or predicate. */ + waitForURL(url: PageUrlMatcher, options?: PageNavigationOptions): Promise; + + /** Read the current page URL. */ + url(): string; + + /** Wait for a fixed duration. Prefer runtime events when possible. */ + waitForTimeout(timeout: number): Promise; +} + +/** Supported adapter runtime labels. */ +export type PageAdapterMode = 'jsdom' | 'playwright'; diff --git a/packages/cellix/serenity-framework/src/pages/page-object.test.ts b/packages/cellix/serenity-framework/src/pages/page-object.test.ts new file mode 100644 index 000000000..d2a7f2280 --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-object.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { AdapterBackedPageObject, type PageAdapter } from './index.ts'; + +class TestPage extends AdapterBackedPageObject { + get currentUrl(): string { + return this.adapter.url(); + } +} + +describe('AdapterBackedPageObject', () => { + it('keeps page objects bound to their runtime adapter', () => { + const adapter = { + url: () => 'https://example.test', + } as PageAdapter; + + const page = new TestPage(adapter); + + expect(page.adapter).toBe(adapter); + expect(page.currentUrl).toBe('https://example.test'); + }); +}); diff --git a/packages/cellix/serenity-framework/src/pages/page-object.ts b/packages/cellix/serenity-framework/src/pages/page-object.ts new file mode 100644 index 000000000..1b7f70801 --- /dev/null +++ b/packages/cellix/serenity-framework/src/pages/page-object.ts @@ -0,0 +1,38 @@ +import type { PageAdapter } from './page-adapter.ts'; + +/** + * Contract for page objects backed by a {@link PageAdapter}. + * + * The interface intentionally requires only the adapter relationship. Consumer + * packages define domain-specific methods and locators on their own page object + * classes while preserving the common adapter-based pattern. + */ +export interface PageObject { + /** Runtime-neutral adapter used by the page object. */ + readonly adapter: TAdapter; +} + +/** + * Base class for adapter-backed page objects. + * + * Extend this class when a page object should work against multiple runtimes, + * such as jsdom for acceptance UI tests and Playwright for browser E2E tests. + * + * @example + * ```ts + * class LoginPage extends AdapterBackedPageObject { + * async submit(email: string): Promise { + * await this.adapter.getByLabel('Email').fill(email); + * await this.adapter.getByRole('button', { name: /Sign in/i }).click(); + * } + * } + * ``` + */ +export abstract class AdapterBackedPageObject implements PageObject { + /** + * Create a page object backed by a runtime-specific adapter. + * + * @param adapter Adapter that performs DOM or browser operations. + */ + constructor(public readonly adapter: TAdapter) {} +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts b/packages/cellix/serenity-framework/src/serenity/browse-the-web.ts similarity index 61% rename from packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts rename to packages/cellix/serenity-framework/src/serenity/browse-the-web.ts index 6763e0dea..5eb11f69d 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/abilities/browse-the-web.ts +++ b/packages/cellix/serenity-framework/src/serenity/browse-the-web.ts @@ -4,28 +4,54 @@ import type { BrowserContext, Page } from 'playwright'; const actorBrowserMap = new Map(); let fallbackInstance: BrowseTheWeb | undefined; +/** + * Serenity ability that exposes a Playwright page and browser context. + * + * The ability supports a current fallback page for single-browser test suites + * and optional actor registration for multi-actor scenarios. + */ export class BrowseTheWeb extends Ability { + /** Playwright page used by tasks and page adapters. */ readonly page: Page; private readonly context: BrowserContext; private actorName: string | undefined; + /** + * Create and activate a browser ability. + * + * @param page Playwright page assigned to the ability. + * @param context Playwright browser context that owns the page. + */ static using(page: Page, context: BrowserContext): BrowseTheWeb { const ability = new BrowseTheWeb(page, context); fallbackInstance = ability; return ability; } + /** + * Register this ability for a named actor. + * + * @param name Actor name used by Serenity/JS. + */ registerForActor(name: string): this { this.actorName = name; actorBrowserMap.set(name, this); return this; } + /** + * Resolve the browser ability for an actor. + * + * @param actor Serenity actor or ability host. + * @throws Error when no actor-specific or fallback browser ability exists. + */ static withActor(actor: UsesAbilities): BrowseTheWeb { const actorName = 'name' in actor ? (actor as Actor).name : undefined; if (actorName) { const perActor = actorBrowserMap.get(actorName); - if (perActor) return perActor; + if (perActor) { + return perActor; + } } if (!fallbackInstance) { @@ -34,6 +60,9 @@ export class BrowseTheWeb extends Ability { return fallbackInstance; } + /** + * Return the active fallback browser ability, if one has been registered. + */ static current(): BrowseTheWeb | undefined { return fallbackInstance; } @@ -44,10 +73,14 @@ export class BrowseTheWeb extends Ability { this.context = context; } + /** Browser context that owns the page. */ get browserContext(): BrowserContext { return this.context; } + /** + * Close only the current page and detach this ability from the registry. + */ async closePageOnly(): Promise { if (!this.page.isClosed()) { await this.page.close(); @@ -55,6 +88,9 @@ export class BrowseTheWeb extends Ability { this.detach(); } + /** + * Close the page and browser context, then detach this ability. + */ async close(): Promise { if (!this.page.isClosed()) { await this.page.close(); diff --git a/packages/cellix/serenity-framework/src/serenity/browser.ts b/packages/cellix/serenity-framework/src/serenity/browser.ts new file mode 100644 index 000000000..591c56df4 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/browser.ts @@ -0,0 +1 @@ +export { BrowseTheWeb } from './browse-the-web.ts'; diff --git a/packages/cellix/serenity-framework/src/serenity/cast.ts b/packages/cellix/serenity-framework/src/serenity/cast.ts new file mode 100644 index 000000000..16e694681 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/cast.ts @@ -0,0 +1,44 @@ +import { type Ability, type Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core'; + +/** Factory that creates a Serenity ability for an actor. */ +export type SerenityAbilityFactory = (actor: Actor) => Ability; + +/** Options used by {@link SerenityCast}. */ +export interface SerenityCastOptions { + /** Ability factories added to every prepared actor. */ + abilities?: SerenityAbilityFactory[]; + + /** Whether each actor receives a Serenity notepad. */ + useNotepad: boolean; +} + +/** + * Generic Serenity cast for Cellix verification suites. + * + * Consumers provide the ability factories their suite needs. The framework + * supplies a single cast implementation so suites do not need local cast + * subclasses for common GraphQL, browser, or notepad-only actor setup. + */ +export class SerenityCast extends Cast { + private readonly abilities: SerenityAbilityFactory[]; + private readonly useNotepad: boolean; + + /** + * @param options Ability factories and notepad behavior. + */ + constructor(options: SerenityCastOptions) { + super(); + this.abilities = options.abilities ?? []; + this.useNotepad = options.useNotepad; + } + + /** + * Prepare an actor with the configured abilities. + * + * @param actor Actor created by Serenity/JS. + */ + prepare(actor: Actor): Actor { + const abilities = this.abilities.map((factory) => factory(actor)); + return this.useNotepad ? actor.whoCan(TakeNotes.using(Notepad.empty()), ...abilities) : actor.whoCan(...abilities); + } +} diff --git a/packages/cellix/serenity-framework/src/serenity/index.ts b/packages/cellix/serenity-framework/src/serenity/index.ts new file mode 100644 index 000000000..51a735837 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/index.ts @@ -0,0 +1,3 @@ +export type { SerenityAbilityFactory, SerenityCastOptions } from './cast.ts'; +export { SerenityCast } from './cast.ts'; +export { TaskStep } from './task-step.ts'; diff --git a/packages/cellix/serenity-framework/src/serenity/task-step.test.ts b/packages/cellix/serenity-framework/src/serenity/task-step.test.ts new file mode 100644 index 000000000..669d0b392 --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/task-step.test.ts @@ -0,0 +1,16 @@ +import type { PerformsActivities } from '@serenity-js/core'; +import { describe, expect, it } from 'vitest'; +import { TaskStep } from './index.ts'; + +describe('TaskStep', () => { + it('executes the supplied action with the actor', async () => { + const actor = { name: 'Alice' } as unknown as PerformsActivities & { name: string }; + let observedActor: typeof actor | undefined; + + await new TaskStep('#actor does something useful', (currentActor) => { + observedActor = currentActor; + }).performAs(actor); + + expect(observedActor).toBe(actor); + }); +}); diff --git a/packages/cellix/serenity-framework/src/serenity/task-step.ts b/packages/cellix/serenity-framework/src/serenity/task-step.ts new file mode 100644 index 000000000..dbd38f41b --- /dev/null +++ b/packages/cellix/serenity-framework/src/serenity/task-step.ts @@ -0,0 +1,29 @@ +import { type PerformsActivities, Task } from '@serenity-js/core'; + +/** + * Serenity task backed by an inline async action. + * + * Use `TaskStep` to keep domain step/task code expressive while avoiding small + * helper functions that only bridge Serenity's `Task` contract. + */ +export class TaskStep extends Task { + /** + * @param description Serenity report description for the task. + * @param action Action executed when the actor performs the task. + */ + constructor( + description: string, + private readonly action: (actor: TActor) => Promise | void, + ) { + super(description); + } + + /** + * Execute the configured action for the supplied actor. + * + * @param actor Actor provided by Serenity/JS. + */ + async performAs(actor: PerformsActivities): Promise { + await this.action(actor as TActor); + } +} diff --git a/packages/cellix/serenity-framework/src/servers/api-test-server.ts b/packages/cellix/serenity-framework/src/servers/api-test-server.ts new file mode 100644 index 000000000..09b927560 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/api-test-server.ts @@ -0,0 +1,13 @@ +import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; + +/** Options used by {@link ApiTestServer}. */ +export type ApiTestServerOptions = ProcessTestServerOptions; + +/** + * Process-backed API server for verification suites. + * + * The framework supplies lifecycle behavior only. Consumers provide every + * command, path, URL, readiness marker, environment value, and probe required + * by their application. + */ +export class ApiTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts b/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts new file mode 100644 index 000000000..625b57e5e --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/apollo-graphql-test-server.ts @@ -0,0 +1,94 @@ +import { ApolloServer, type BaseContext } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import type { GraphQLSchema, ValidationRule } from 'graphql'; +import depthLimit from 'graphql-depth-limit'; +import type { TestServer } from './test-server.ts'; + +const MAX_QUERY_DEPTH = 25; + +/** Options used by {@link ApolloGraphQLTestServer}. */ +export interface ApolloGraphQLTestServerOptions { + /** GraphQL schema served by Apollo. */ + schema: GraphQLSchema; + + /** Context factory passed to Apollo's standalone server. */ + context: Parameters>[1]['context']; + + /** Optional GraphQL validation rules. */ + validationRules?: ValidationRule[]; + + /** Whether batched HTTP requests are allowed. Defaults to `true`. */ + allowBatchedHttpRequests?: boolean; + + /** Whether Apollo introspection is enabled. Defaults to `false`. */ + introspection?: boolean; +} + +/** + * Generic in-process Apollo GraphQL server for acceptance tests. + * + * Consumers provide schema, context, validation rules, and app-specific service + * factories from outside the Cellix framework package. + */ +export class ApolloGraphQLTestServer implements TestServer { + private server: ApolloServer | null = null; + private url: string | null = null; + + /** + * @param options Apollo server contract supplied by the consumer. + */ + constructor(private readonly options: ApolloGraphQLTestServerOptions) {} + + /** + * Start the GraphQL server on the specified port, or a random port by default. + * + * @param port TCP port. Use `0` for any available port. + */ + async start(port = 0): Promise { + if (this.server) { + throw new Error('ApolloGraphQLTestServer already started'); + } + + this.server = new ApolloServer({ + allowBatchedHttpRequests: this.options.allowBatchedHttpRequests ?? true, + introspection: this.options.introspection ?? false, + schema: this.options.schema, + validationRules: [depthLimit(MAX_QUERY_DEPTH), ...(this.options.validationRules ?? [])], + }); + + const { url } = await startStandaloneServer(this.server, { + context: this.options.context, + listen: { port }, + }); + + this.url = url; + } + + /** Stop the Apollo server. */ + async stop(): Promise { + if (!this.server) { + return; + } + + await this.server.stop(); + this.server = null; + this.url = null; + } + + /** + * Return the server URL. + * + * @throws Error when the server has not started. + */ + getUrl(): string { + if (!this.url) { + throw new Error('ApolloGraphQLTestServer not started'); + } + return this.url; + } + + /** Return whether the server is active. */ + isRunning(): boolean { + return this.server !== null; + } +} diff --git a/packages/cellix/serenity-framework/src/servers/auth-test-server.ts b/packages/cellix/serenity-framework/src/servers/auth-test-server.ts new file mode 100644 index 000000000..57891aabd --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/auth-test-server.ts @@ -0,0 +1,12 @@ +import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; + +/** Options used by {@link AuthTestServer}. */ +export type AuthTestServerOptions = ProcessTestServerOptions; + +/** + * Process-backed authentication server for verification suites. + * + * Consumers provide the complete descriptor so the framework remains ignorant + * of local auth tools, hostnames, ports, and startup commands. + */ +export class AuthTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts b/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts new file mode 100644 index 000000000..b219c6700 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts @@ -0,0 +1,13 @@ +import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; + +/** Options used by {@link AzuriteTestServer}. */ +export type AzuriteTestServerOptions = ProcessTestServerOptions; + +/** + * Process-backed Azurite server for verification suites. + * + * All app-specific command, port, environment, and readiness details are + * supplied by the consumer so the framework package stays ignorant of local + * workspace conventions. + */ +export class AzuriteTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/index.ts b/packages/cellix/serenity-framework/src/servers/index.ts new file mode 100644 index 000000000..18a418b72 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/index.ts @@ -0,0 +1,18 @@ +export type { ApiTestServerOptions } from './api-test-server.ts'; +export { ApiTestServer } from './api-test-server.ts'; +export type { ApolloGraphQLTestServerOptions } from './apollo-graphql-test-server.ts'; +export { ApolloGraphQLTestServer } from './apollo-graphql-test-server.ts'; +export type { AuthTestServerOptions } from './auth-test-server.ts'; +export { AuthTestServer } from './auth-test-server.ts'; +export type { AzuriteTestServerOptions } from './azurite-test-server.ts'; +export { AzuriteTestServer } from './azurite-test-server.ts'; +export type { MongoMemorySeedContext, MongoMemorySeedDataFunction, MongoMemoryTestServerOptions } from './mongo-memory-test-server.ts'; +export { MongoMemoryTestServer } from './mongo-memory-test-server.ts'; +export { createSpawnEnvironment } from './process-environment.ts'; +export type { ProcessHealthProbe, ProcessTestServerOptions } from './process-test-server.ts'; +export { ProcessTestServer } from './process-test-server.ts'; +export type { SeedDataFunction, TestServer } from './test-server.ts'; +export type { TestServerGroupOptions } from './test-server-group.ts'; +export { TestServerGroup } from './test-server-group.ts'; +export type { UiPortalTestServerOptions } from './ui-portal-test-server.ts'; +export { UiPortalTestServer } from './ui-portal-test-server.ts'; diff --git a/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts b/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts new file mode 100644 index 000000000..f75cae48f --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/mongo-memory-test-server.ts @@ -0,0 +1,122 @@ +import { type MongoMemoryReplicaSetConfig, type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; +import { MongoClient } from 'mongodb'; +import type { SeedDataFunction, TestServer } from './test-server.ts'; + +/** Context supplied to Mongo seed functions. */ +export interface MongoMemorySeedContext { + /** MongoDB connection string. */ + connectionString: string; + + /** Database name used by the test server. */ + dbName: string; +} + +/** Seed function used by {@link MongoMemoryTestServer}. */ +export type MongoMemorySeedDataFunction = SeedDataFunction; + +/** Options used by {@link MongoMemoryTestServer}. */ +export interface MongoMemoryTestServerOptions { + /** Database name. */ + dbName: string; + + /** MongoDB port. */ + port: number; + + /** Replica set name. */ + replSetName: string; + + /** MongoDB binary version. */ + binaryVersion?: string; + + /** Optional seed function called after startup and reset. */ + seedData?: MongoMemorySeedDataFunction; +} + +/** + * Reusable in-memory MongoDB replica set for verification tests. + * + * The server is Cellix-only and does not attach application-specific Mongoose + * services. Consumers can seed data through the supplied callback. + */ +export class MongoMemoryTestServer implements TestServer { + private disposer: MongoMemoryReplicaSetDisposer | null = null; + private connectionString = ''; + + /** + * @param options Complete MongoDB memory replica set configuration. + */ + constructor(private readonly options: MongoMemoryTestServerOptions) {} + + /** Start the Mongo memory replica set. */ + async start(): Promise { + const config: MongoMemoryReplicaSetConfig = { + dbName: this.options.dbName, + port: this.options.port, + replSetName: this.options.replSetName, + ...(this.options.binaryVersion && { binaryVersion: this.options.binaryVersion }), + }; + + const { connectionString, disposer } = await startMongoMemoryReplicaSet(config); + this.disposer = disposer; + this.connectionString = connectionString; + await this.seed(); + } + + /** Return the MongoDB connection string. */ + getConnectionString(): string { + if (!this.connectionString) { + throw new Error('MongoMemoryTestServer not started'); + } + return this.connectionString; + } + + /** Alias for {@link getConnectionString}. */ + getUrl(): string { + return this.getConnectionString(); + } + + /** + * Clear all collections and re-run seed data. + * + * @param seedData Optional seed override for this reset. + */ + async resetForScenario(seedData?: MongoMemorySeedDataFunction): Promise { + if (!this.connectionString) { + throw new Error('MongoMemoryTestServer not started'); + } + + await clearDatabase({ connectionString: this.connectionString, dbName: this.options.dbName }); + await this.seed(seedData); + } + + /** Stop the replica set. */ + async stop(): Promise { + if (this.disposer) { + const disposer = this.disposer; + this.disposer = null; + await disposer.stop(); + } + this.connectionString = ''; + } + + /** Return whether the replica set is active. */ + isRunning(): boolean { + return this.disposer !== null; + } + + private async seed(seedData = this.options.seedData): Promise { + await seedData?.({ connectionString: this.connectionString, dbName: this.options.dbName }); + } +} + +async function clearDatabase(context: MongoMemorySeedContext): Promise { + const client = new MongoClient(context.connectionString); + try { + await client.connect(); + const db = client.db(context.dbName); + const collections = await db.listCollections({}, { nameOnly: true }).toArray(); + await Promise.all(collections.map((collection) => db.collection(collection.name).deleteMany({}))); + } finally { + await client.close(); + } +} diff --git a/packages/cellix/serenity-framework/src/servers/process-environment.ts b/packages/cellix/serenity-framework/src/servers/process-environment.ts new file mode 100644 index 000000000..7668a31df --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/process-environment.ts @@ -0,0 +1,11 @@ +/** + * Build a child-process environment while removing inherited Node loader hooks. + * + * This prevents test runner `NODE_OPTIONS` from leaking into app dev servers. + * + * @param overrides Environment variables applied after the current process env. + */ +export function createSpawnEnvironment(overrides: Record = {}): NodeJS.ProcessEnv { + const { NODE_OPTIONS: _ignored, ...baseEnv } = process.env; + return { ...baseEnv, ...overrides }; +} diff --git a/packages/cellix/serenity-framework/src/servers/process-test-server.ts b/packages/cellix/serenity-framework/src/servers/process-test-server.ts new file mode 100644 index 000000000..98954d96b --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/process-test-server.ts @@ -0,0 +1,293 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { createSpawnEnvironment } from './process-environment.ts'; +import type { TestServer } from './test-server.ts'; + +/** Configuration for health probes used by {@link ProcessTestServer}. */ +export interface ProcessHealthProbe { + /** URL to probe after the ready marker is observed. */ + url: string | (() => string); + + /** Request options supplied to `fetch`. */ + requestInit?: RequestInit | (() => RequestInit); + + /** Predicate that decides whether the probe response is healthy. Defaults to `response.ok`. */ + isHealthy?: (response: Response) => boolean | Promise; +} + +/** Options used by {@link ProcessTestServer}. */ +export interface ProcessTestServerOptions { + /** Human-readable name used in error messages. */ + serverName: string; + + /** Executable to spawn. */ + executable: string | (() => string); + + /** Arguments supplied to the executable. */ + spawnArgs: string[] | (() => string[]); + + /** Working directory for the process. */ + cwd: string; + + /** Marker expected on stdout before health probing begins. */ + readyMarker: string | RegExp; + + /** URL exposed by the server. */ + getUrl: () => string; + + /** Additional process environment values. */ + extraEnv?: Record | (() => Record); + + /** Health probe configuration. Defaults to probing `getUrl()`. Use `false` to trust the ready marker. */ + probe?: ProcessHealthProbe | false; + + /** Maximum startup time in milliseconds. */ + startupTimeoutMs?: number | (() => number); + + /** Maximum graceful shutdown time in milliseconds. */ + shutdownTimeoutMs?: number | (() => number); + + /** Individual health probe timeout in milliseconds. */ + healthProbeTimeoutMs?: number | (() => number); + + /** Delay between health probes in milliseconds. */ + healthProbeIntervalMs?: number | (() => number); + + /** Return true when the server is already reachable before spawning. */ + isAlreadyRunning?: () => Promise; + + /** Treat an early process exit as an existing reusable server. */ + isReusableExit?: (stderrOutput: string) => boolean; +} + +/** + * Configurable child-process test server. + * + * Consumers pass app-specific commands, paths, URLs, and probes through the + * descriptor. The framework owns lifecycle, readiness, probing, and shutdown. + */ +export class ProcessTestServer implements TestServer { + private process: ChildProcess | null = null; + private startedByUs = false; + private readonly useDetachedProcessGroup = process.platform !== 'win32'; + + /** + * @param options Process descriptor and lifecycle settings. + */ + constructor(private readonly options: ProcessTestServerOptions) {} + + /** + * Start the process and wait for readiness. + */ + async start(): Promise { + if (this.process || this.startedByUs) { + return; + } + + if (await this.isAlreadyRunning()) { + return; + } + + const executable = this.value(this.options.executable); + const spawnArgs = this.value(this.options.spawnArgs); + if (!executable || !spawnArgs) { + throw new Error(`${this.options.serverName} requires an executable and spawn arguments`); + } + + this.process = spawn(executable, spawnArgs, { + cwd: this.options.cwd, + env: createSpawnEnvironment(this.value(this.options.extraEnv) ?? {}), + detached: this.useDetachedProcessGroup, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.startedByUs = true; + + await this.waitForReady(); + } + + /** + * Stop a process started by this instance. + */ + async stop(): Promise { + if (!this.process || !this.startedByUs) { + return; + } + + const childProcess = this.process; + this.process = null; + this.startedByUs = false; + + this.killProcess(childProcess, 'SIGINT'); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.killProcess(childProcess, 'SIGKILL'); + resolve(); + }, this.value(this.options.shutdownTimeoutMs) ?? 10_000); + + childProcess.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + /** + * Return whether this instance currently owns a running child process. + */ + isRunning(): boolean { + return this.process !== null; + } + + /** + * Return the descriptor URL. + */ + getUrl(): string { + return this.options.getUrl(); + } + + private async isAlreadyRunning(): Promise { + if (this.options.isAlreadyRunning) { + return await this.options.isAlreadyRunning(); + } + return await this.isProbeReadyWithin(this.value(this.options.healthProbeTimeoutMs) ?? 3_000); + } + + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const childProcess = this.process; + if (!childProcess) { + reject(new Error(`${this.options.serverName} process not started`)); + return; + } + + const startupTimeout = this.value(this.options.startupTimeoutMs) ?? 120_000; + const startupDeadline = Date.now() + startupTimeout; + const timeout = setTimeout(() => { + reject(new Error(`${this.options.serverName} did not start within ${startupTimeout}ms`)); + }, startupTimeout); + + let stderrOutput = ''; + let ready = false; + + const resolveWhenReachable = () => { + if (ready) { + return; + } + ready = true; + + this.waitForProbeReady(startupDeadline, startupTimeout) + .then(() => { + clearTimeout(timeout); + resolve(); + }) + .catch((error: unknown) => { + clearTimeout(timeout); + reject(error); + }); + }; + + childProcess.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + if (this.matchesReadyMarker(text)) { + resolveWhenReachable(); + } + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + stderrOutput += data.toString(); + }); + + childProcess.on('error', (error: Error) => { + clearTimeout(timeout); + this.process = null; + this.startedByUs = false; + reject(new Error(`${this.options.serverName} failed to start: ${error.message}`)); + }); + + childProcess.on('exit', (code, signal) => { + if (ready) { + return; + } + clearTimeout(timeout); + this.process = null; + this.startedByUs = false; + + if (this.options.isReusableExit?.(stderrOutput)) { + resolve(); + return; + } + + reject(new Error(`${this.options.serverName} exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); + }); + }); + } + + private async waitForProbeReady(startupDeadline: number, startupTimeout: number): Promise { + const probeInterval = this.value(this.options.healthProbeIntervalMs) ?? 500; + const timeoutError = () => new Error(`${this.options.serverName} did not become healthy within ${startupTimeout}ms`); + + while (true) { + const remainingMs = startupDeadline - Date.now(); + if (remainingMs <= 0) { + throw timeoutError(); + } + + if (await this.isProbeReadyWithin(Math.min(this.value(this.options.healthProbeTimeoutMs) ?? 3_000, remainingMs))) { + return; + } + + const retryDelay = Math.min(probeInterval, startupDeadline - Date.now()); + if (retryDelay <= 0) { + throw timeoutError(); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + private async isProbeReadyWithin(timeoutMs: number): Promise { + if (this.options.probe === false) { + return true; + } + + let timeout: ReturnType | undefined; + try { + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), timeoutMs); + const probe = this.options.probe; + const response = await fetch(this.value(probe?.url) ?? this.getUrl(), { + ...(this.value(probe?.requestInit) ?? {}), + signal: controller.signal, + }); + return probe?.isHealthy ? await probe.isHealthy(response) : response.ok; + } catch { + return false; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + private matchesReadyMarker(text: string): boolean { + const marker = this.options.readyMarker; + return typeof marker === 'string' ? text.includes(marker) : marker.test(text); + } + + private killProcess(childProcess: ChildProcess, signal: NodeJS.Signals): void { + if (this.useDetachedProcessGroup && childProcess.pid) { + try { + process.kill(-childProcess.pid, signal); + return; + } catch { + /* Fall back to killing the direct child. */ + } + } + + childProcess.kill(signal); + } + + private value(value: T | (() => T) | undefined): T | undefined { + return typeof value === 'function' ? (value as () => T)() : value; + } +} diff --git a/packages/cellix/serenity-framework/src/servers/server-group.test.ts b/packages/cellix/serenity-framework/src/servers/server-group.test.ts new file mode 100644 index 000000000..81fb1659d --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/server-group.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { type TestServer, TestServerGroup } from './index.ts'; + +class RecordingServer implements TestServer { + private readonly name: string; + private readonly calls: string[]; + + constructor(name: string, calls: string[]) { + this.name = name; + this.calls = calls; + } + + start(): Promise { + this.calls.push(`start:${this.name}`); + return Promise.resolve(); + } + + stop(): Promise { + this.calls.push(`stop:${this.name}`); + return Promise.resolve(); + } + + isRunning(): boolean { + return false; + } + + getUrl(): string { + return `https://${this.name}.example.test`; + } +} + +describe('TestServerGroup', () => { + it('starts required servers before variable UI portals and stops in reverse groups', async () => { + const calls: string[] = []; + const group = new TestServerGroup({ + required: [new RecordingServer('api', calls), new RecordingServer('auth', calls)], + uiPortals: [new RecordingServer('community', calls), new RecordingServer('staff', calls)], + }); + + await group.start(); + await group.stop(); + + expect(calls).toEqual(['start:api', 'start:auth', 'start:community', 'start:staff', 'stop:staff', 'stop:community', 'stop:auth', 'stop:api']); + }); +}); diff --git a/packages/cellix/serenity-framework/src/servers/test-server-group.ts b/packages/cellix/serenity-framework/src/servers/test-server-group.ts new file mode 100644 index 000000000..349e4f438 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/test-server-group.ts @@ -0,0 +1,54 @@ +import type { TestServer } from './test-server.ts'; + +/** Options used by {@link TestServerGroup}. */ +export interface TestServerGroupOptions { + /** Servers required for the system under test. */ + required: TestServer[]; + + /** Variable UI portal servers. */ + uiPortals?: TestServer[]; +} + +/** + * Starts and stops required servers plus any number of UI portal servers. + * + * Required servers start before UI portals. Shutdown runs in reverse order. + */ +export class TestServerGroup implements TestServer { + private readonly required: TestServer[]; + private readonly uiPortals: TestServer[]; + + /** + * @param options Required servers and optional UI portals. + */ + constructor(options: TestServerGroupOptions) { + this.required = options.required; + this.uiPortals = options.uiPortals ?? []; + } + + /** Start required servers, then all UI portal servers. */ + async start(): Promise { + await Promise.all(this.required.map((server) => server.start())); + await Promise.all(this.uiPortals.map((server) => server.start())); + } + + /** Stop UI portals, then required servers. */ + async stop(): Promise { + await Promise.all([...this.uiPortals].reverse().map((server) => server.stop().catch(() => undefined))); + await Promise.all([...this.required].reverse().map((server) => server.stop().catch(() => undefined))); + } + + /** Return whether any grouped server reports as running. */ + isRunning(): boolean { + return [...this.required, ...this.uiPortals].some((server) => server.isRunning()); + } + + /** + * Server groups do not expose a single URL. + * + * @throws Error always. + */ + getUrl(): string { + throw new Error('TestServerGroup does not expose a single URL'); + } +} diff --git a/packages/cellix/serenity-framework/src/servers/test-server.ts b/packages/cellix/serenity-framework/src/servers/test-server.ts new file mode 100644 index 000000000..1d337d2d0 --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/test-server.ts @@ -0,0 +1,21 @@ +/** + * Common contract for in-process and subprocess test servers. + */ +export interface TestServer { + /** Start the server and resolve when it is ready. */ + start(): Promise; + + /** Stop the server gracefully. */ + stop(): Promise; + + /** Return whether this server instance is currently running. */ + isRunning(): boolean; + + /** Return the URL exposed by the server. */ + getUrl(): string; +} + +/** + * Seed function used by database-oriented test servers. + */ +export type SeedDataFunction = (context: TContext) => Promise | void; diff --git a/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts b/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts new file mode 100644 index 000000000..44221eaee --- /dev/null +++ b/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts @@ -0,0 +1,13 @@ +import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; + +/** Options used by {@link UiPortalTestServer}. */ +export type UiPortalTestServerOptions = ProcessTestServerOptions; + +/** + * Generic UI portal server for browser E2E suites. + * + * Consumers create one instance per portal and provide every command, path, + * readiness marker, environment value, and URL. The framework intentionally + * does not default to any dev server, executable, or portal naming convention. + */ +export class UiPortalTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/settings/index.ts b/packages/cellix/serenity-framework/src/settings/index.ts new file mode 100644 index 000000000..2fac27525 --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/index.ts @@ -0,0 +1,2 @@ +export type { DefaultVerificationTimeoutKey, VerificationTimeoutMap, VerificationTimeoutOptions } from './timeout-settings.ts'; +export { defaultVerificationTimeouts, getTimeout, VerificationTimeouts } from './timeout-settings.ts'; diff --git a/packages/cellix/serenity-framework/src/settings/settings.test.ts b/packages/cellix/serenity-framework/src/settings/settings.test.ts new file mode 100644 index 000000000..e3c2afc31 --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { VerificationTimeouts } from './index.ts'; + +describe('VerificationTimeouts', () => { + it('uses positive integer environment overrides', () => { + const timeouts = new VerificationTimeouts({ + defaults: { serverStartup: 100 }, + env: { TIMEOUT_SERVER_STARTUP: '250' }, + }); + + expect(timeouts.get('serverStartup')).toBe(250); + }); + + it('falls back to defaults for invalid overrides', () => { + const timeouts = new VerificationTimeouts({ + defaults: { serverStartup: 100 }, + env: { TIMEOUT_SERVER_STARTUP: 'nope' }, + }); + + expect(timeouts.get('serverStartup')).toBe(100); + }); +}); diff --git a/packages/cellix/serenity-framework/src/settings/timeout-settings.ts b/packages/cellix/serenity-framework/src/settings/timeout-settings.ts new file mode 100644 index 000000000..17abeeaac --- /dev/null +++ b/packages/cellix/serenity-framework/src/settings/timeout-settings.ts @@ -0,0 +1,93 @@ +/** + * Default timeout map used by Cellix verification packages. + */ +export const defaultVerificationTimeouts = { + /** Default Cucumber scenario timeout. */ + scenario: 120_000, + + /** Server startup timeout. */ + serverStartup: 120_000, + + /** Graceful server shutdown timeout. */ + serverShutdown: 10_000, + + /** Health probe timeout. */ + healthProbe: 3_000, + + /** Health probe retry interval. */ + healthProbeInterval: 500, + + /** UI initialization timeout. */ + uiInit: 30_000, + + /** UI cleanup timeout. */ + uiCleanup: 10_000, +} as const; + +/** Keys accepted by the default timeout map. */ +export type DefaultVerificationTimeoutKey = keyof typeof defaultVerificationTimeouts; + +/** Timeout map accepted by {@link VerificationTimeouts}. */ +export type VerificationTimeoutMap = Record; + +/** Options used by {@link VerificationTimeouts}. */ +export interface VerificationTimeoutOptions { + /** Default timeout values. */ + defaults: TTimeouts; + + /** Environment source. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Reads verification timeouts with optional environment overrides. + * + * Environment variable names are generated from keys: `serverStartup` becomes + * `TIMEOUT_SERVER_STARTUP`. + */ +export class VerificationTimeouts { + private readonly defaults: TTimeouts; + private readonly env: NodeJS.ProcessEnv; + + /** + * @param options Timeout defaults and optional environment source. + */ + constructor(options: VerificationTimeoutOptions) { + this.defaults = options.defaults; + this.env = options.env ?? process.env; + } + + /** + * Get a timeout value, honoring a positive integer environment override. + * + * @param key Timeout key. + */ + get(key: TKey): TTimeouts[TKey] { + const envName = timeoutEnvName(key); + const envOverride = this.env[envName]; + + if (envOverride) { + const parsed = Number(envOverride); + if (Number.isInteger(parsed) && parsed > 0) { + return parsed as TTimeouts[TKey]; + } + } + + return this.defaults[key]; + } +} + +const defaultTimeoutReader = new VerificationTimeouts({ defaults: defaultVerificationTimeouts }); + +/** + * Read a timeout from the default Cellix verification timeout map. + * + * @param key Timeout key. + */ +export function getTimeout(key: DefaultVerificationTimeoutKey): number { + return defaultTimeoutReader.get(key); +} + +function timeoutEnvName(key: string): string { + return `TIMEOUT_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`; +} diff --git a/packages/cellix/serenity-framework/tsconfig.json b/packages/cellix/serenity-framework/tsconfig.json new file mode 100644 index 000000000..39ba28f8b --- /dev/null +++ b/packages/cellix/serenity-framework/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "erasableSyntaxOnly": false, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cellix/serenity-framework/tsconfig.vitest.json b/packages/cellix/serenity-framework/tsconfig.vitest.json new file mode 100644 index 000000000..4f806efbc --- /dev/null +++ b/packages/cellix/serenity-framework/tsconfig.vitest.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"] +} diff --git a/packages/cellix/serenity-framework/turbo.json b/packages/cellix/serenity-framework/turbo.json new file mode 100644 index 000000000..5f90b32dd --- /dev/null +++ b/packages/cellix/serenity-framework/turbo.json @@ -0,0 +1,3 @@ +{ + "extends": ["//"] +} diff --git a/packages/cellix/serenity-framework/vitest.config.ts b/packages/cellix/serenity-framework/vitest.config.ts new file mode 100644 index 000000000..e6ec21dc3 --- /dev/null +++ b/packages/cellix/serenity-framework/vitest.config.ts @@ -0,0 +1,8 @@ +import { nodeConfig } from '@cellix/config-vitest'; +import { mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, { + test: { + exclude: ['dist/**', 'node_modules/**'], + }, +}); diff --git a/packages/ocom-verification/acceptance-api/cucumber.js b/packages/ocom-verification/acceptance-api/cucumber.js index d92e97982..b5fb90c59 100644 --- a/packages/ocom-verification/acceptance-api/cucumber.js +++ b/packages/ocom-verification/acceptance-api/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 5887447d4..c633606d9 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -18,17 +18,23 @@ "@serenity-js/core": "catalog:", "@serenity-js/cucumber": "catalog:", "@serenity-js/serenity-bdd": "catalog:", + "@cellix/serenity-framework": "workspace:*", + "graphql": "catalog:", + "graphql-depth-limit": "^1.1.0", + "graphql-middleware": "^6.1.35", "std-env": "^4.0.0" }, "devDependencies": { "@cellix/config-typescript": "workspace:*", "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", + "@ocom/graphql": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/service-apollo-server": "workspace:*", "@ocom/service-mongoose": "workspace:*", "@ocom/service-token-validation": "workspace:*", "@ocom-verification/verification-shared": "workspace:*", + "@types/graphql-depth-limit": "^1.1.6", "@types/node": "catalog:", "c8": "^11.0.0", "rimraf": "^6.0.1", diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/notes/header-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/acceptance-api/src/contexts/authentication/notes/header-notes.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts index d204f57f9..f58908f5a 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -1,6 +1,6 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, notes } from '@serenity-js/core'; -import type { HeaderApiNotes } from '../abilities/header-types.ts'; +import type { HeaderApiNotes } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; let lastActorName = 'Alex'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts index b0e46f773..04fc28721 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,6 +1,6 @@ -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; -import type { HeaderApiNotes } from '../abilities/header-types.ts'; +import type { HeaderApiNotes } from '../notes/header-notes.ts'; export const ClickHeaderSignIn = () => Task.where( diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts index c815549eb..c997a105d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-name.ts @@ -1,7 +1,7 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; import { GET_COMMUNITY_QUERY } from '../../../shared/graphql/community-operations.ts'; -import type { CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; export class CommunityName extends Question> { constructor() { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts index 598bfffd9..0da828fe0 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-status.ts @@ -1,5 +1,5 @@ import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import type { CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; export class CommunityStatus extends Question> { constructor() { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts index c1f727e2c..a8a7bddce 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts @@ -1,9 +1,9 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; -import { Ensure, equals } from '@serenity-js/assertions'; import { actorCalled, notes } from '@serenity-js/core'; -import { resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; -import type { CommunityDetails, CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityDetails, CommunityNotes } from '../notes/community-notes.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CommunityStatus } from '../questions/community-status.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; @@ -18,7 +18,7 @@ Given('{word} is an authenticated community owner', (actorName: string) => { When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash() as unknown as CommunityDetails; + const details = GherkinDataTable.from(dataTable).rowsHash(); await actor.attemptsTo(CreateCommunity.with(details)); }); @@ -26,7 +26,7 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash() as unknown as CommunityDetails; + const details = GherkinDataTable.from(dataTable).rowsHash(); await actor.attemptsTo(notes().set('lastCommunityId', undefined as unknown as string), notes().set('lastValidationError', undefined as unknown as string)); @@ -40,18 +40,24 @@ When('{word} attempts to create a community with:', async (actorName: string, da Then('the community should be created successfully', async () => { const actor = actorCalled(lastActorName); + const status = await actor.answer(CommunityStatus.of()); - await actor.attemptsTo(Ensure.that(CommunityStatus.of(), equals('SUCCESS'))); + if (status !== 'SUCCESS') { + throw new Error(`Expected community status "SUCCESS" but got "${status}"`); + } }); Then('the community name should be {string}', async (expectedName: string) => { const actor = actorCalled(lastActorName); + const actualName = await actor.answer(CommunityName.displayed()); - await actor.attemptsTo(Ensure.that(CommunityName.displayed(), equals(expectedName))); + if (actualName !== expectedName) { + throw new Error(`Expected community name "${expectedName}" but got "${actualName}"`); + } }); Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedActorName = resolveActorName(actorName, lastActorName); + const resolvedActorName = ActorName.resolve(actorName, { defaultName: lastActorName }); const actor = actorCalled(resolvedActorName); let storedError: string | undefined; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts index 0f530b08f..118c7fbee 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-community.ts @@ -1,7 +1,6 @@ +import { CreateCommunity as CreateCommunityAbility } from '@ocom-verification/verification-shared/abilities'; import { type Actor, notes, Task } from '@serenity-js/core'; -import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; -import { COMMUNITY_CREATE_MUTATION, GET_COMMUNITY_QUERY } from '../../../shared/graphql/community-operations.ts'; -import type { CommunityDetails, CommunityNotes } from '../abilities/community-types.ts'; +import type { CommunityDetails, CommunityNotes } from '../notes/community-notes.ts'; export class CreateCommunity extends Task { static withName(name: string) { @@ -17,43 +16,9 @@ export class CreateCommunity extends Task { } async performAs(actor: Actor): Promise { - const graphql = GraphQLClient.as(actor); + const community = await CreateCommunityAbility.as(actor).performAs(actor, this.details); - const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { - input: { name: this.details.name }, - }); - - const mutationResult = response.data['communityCreate'] as Record; - const status = mutationResult?.['status'] as Record | undefined; - const community = mutationResult?.['community'] as Record | undefined; - - if (status?.['success'] !== true) { - throw new Error(String(status?.['errorMessage'] ?? 'Failed to create community')); - } - - const communityId = String(community?.['id'] ?? ''); - const communityName = String(community?.['name'] ?? ''); - - if (!communityId) { - throw new Error('API communityCreate returned a community without an id'); - } - if (communityName !== this.details.name) { - throw new Error(`API communityCreate returned name "${communityName}", expected "${this.details.name}"`); - } - - const persistedResponse = await graphql.execute(GET_COMMUNITY_QUERY, { - id: communityId, - }); - const persistedData = persistedResponse.data['communityById'] as Record | undefined; - if (!persistedData) { - throw new Error(`Community ${communityId} was not found on re-query; API backend did not persist the community`); - } - const persistedName = String(persistedData['name'] ?? ''); - if (persistedName !== this.details.name) { - throw new Error(`Re-queried community name "${persistedName}" does not match created name "${this.details.name}"`); - } - - await actor.attemptsTo(notes().set('lastCommunityId', communityId), notes().set('lastCommunityName', communityName), notes().set('lastCommunityStatus', 'SUCCESS')); + await actor.attemptsTo(notes().set('lastCommunityId', community.id ?? ''), notes().set('lastCommunityName', community.name), notes().set('lastCommunityStatus', 'SUCCESS')); } override toString = () => `creates a community named "${this.details.name}"`; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts new file mode 100644 index 000000000..840208333 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts @@ -0,0 +1,47 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { CreateCommunity } from '@ocom-verification/verification-shared/abilities'; +import { COMMUNITY_CREATE_MUTATION, GET_COMMUNITY_QUERY } from '../graphql/community-operations.ts'; + +export function createCommunityAbility(): CreateCommunity { + return CreateCommunity.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { + input: { name: details.name }, + }); + + const mutationResult = response.data['communityCreate'] as Record; + const status = mutationResult?.['status'] as Record | undefined; + const community = mutationResult?.['community'] as Record | undefined; + + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? 'Failed to create community')); + } + + const communityId = String(community?.['id'] ?? ''); + const communityName = String(community?.['name'] ?? ''); + + if (!communityId) { + throw new Error('API communityCreate returned a community without an id'); + } + if (communityName !== details.name) { + throw new Error(`API communityCreate returned name "${communityName}", expected "${details.name}"`); + } + + const persistedResponse = await graphql.execute(GET_COMMUNITY_QUERY, { + id: communityId, + }); + const persistedData = persistedResponse.data['communityById'] as Record | undefined; + if (!persistedData) { + throw new Error(`Community ${communityId} was not found on re-query; API backend did not persist the community`); + } + const persistedName = String(persistedData['name'] ?? ''); + if (persistedName !== details.name) { + throw new Error(`Re-queried community name "${persistedName}" does not match created name "${details.name}"`); + } + + return { + id: communityId, + name: communityName, + }; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts index 2e6a8beb4..6dd954aea 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts @@ -1,40 +1,10 @@ -import { Ability } from '@serenity-js/core'; - -interface GraphQLResponse { - data: Record; - errors?: Array<{ message: string }>; -} - -export class GraphQLClient extends Ability { - constructor(private readonly apiUrl: string) { - super(); - } - - static at(apiUrl: string): GraphQLClient { - return new GraphQLClient(apiUrl); - } - - async execute(query: string, variables: Record = {}): Promise { - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer test-token', - }, - body: JSON.stringify({ query, variables }), - }); - - const result = (await response.json()) as GraphQLResponse; - - if (result.errors && Array.isArray(result.errors)) { - const errorMessage = result.errors.map((err) => err.message ?? 'Unknown error').join('; '); - throw new Error(errorMessage); - } - - if (!response.ok) { - throw new Error(`GraphQL error: ${response.status} ${response.statusText}`); - } - - return result; - } +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; + +export function createGraphQLClientAbility(apiUrl: string): GraphQLClient { + return new GraphQLClient({ + apiUrl, + headers: { + Authorization: 'Bearer test-token', + }, + }); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts new file mode 100644 index 000000000..2ed5ecf3a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -0,0 +1,2 @@ +export { createCommunityAbility } from './create-community.ts'; +export { createGraphQLClientAbility } from './graphql-client.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/application-services/index.ts b/packages/ocom-verification/acceptance-api/src/shared/application-services/index.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/shared/support/application-services/index.ts rename to packages/ocom-verification/acceptance-api/src/shared/application-services/index.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/application-services/mock-application-services.ts similarity index 100% rename from packages/ocom-verification/acceptance-api/src/shared/support/application-services/mock-application-services.ts rename to packages/ocom-verification/acceptance-api/src/shared/application-services/mock-application-services.ts diff --git a/packages/ocom-verification/acceptance-api/src/shared/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-api/src/shared/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..e25861501 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/cucumber-lifecycle-hooks.ts @@ -0,0 +1,24 @@ +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import type { IWorld } from '@cucumber/cucumber'; +import { isAgent } from 'std-env'; +import { type CellixApiWorld, stopSharedServers } from '../world.ts'; + +let printedSuiteHeader = false; + +registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + before: async (world) => { + if (!printedSuiteHeader && !isAgent) { + printedSuiteHeader = true; + console.log('\nAPI acceptance tests'); + console.log(' - Community context\n'); + } + + await world.init(); + }, + after: async (world) => { + await world.cleanup(); + }, + afterAll: stopSharedServers, +}); diff --git a/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts b/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts new file mode 100644 index 000000000..0531dba81 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts @@ -0,0 +1,39 @@ +import { ApiInfrastructure, type ApiInfrastructureState } from '@cellix/serenity-framework/infrastructure/api'; +import type { ServiceMongoose } from '@ocom/service-mongoose'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; +import { createApiGraphQLServer, createApiMongooseService, resetApiGraphQLServerFactories } from './test-server-factories.ts'; + +const apiDbName = 'owner-community'; + +const infrastructure = ApiInfrastructure.using({ + createApiServer: ({ getMongooseService }) => createApiGraphQLServer(getMongooseService), + mongoServer: { + dbName: apiDbName, + port: 50_000, + replSetName: 'globaldb', + seedData: seedDatabase, + }, + mongoose: { + createService: (connectionString) => createApiMongooseService(connectionString, apiDbName), + }, +}); + +interface InfrastructureState extends ApiInfrastructureState {} + +export function getState(): InfrastructureState { + return infrastructure.getState(); +} + +export async function stopAll(): Promise { + await infrastructure.stopAll(); + resetApiGraphQLServerFactories(); +} + +export async function ensureApiServers(): Promise { + infrastructure.registerProcessShutdownHandlers(); + await infrastructure.ensureStarted(); +} + +export async function resetMongoForScenario(): Promise { + await infrastructure.resetScenarioState(); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts b/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts deleted file mode 100644 index 1e92882bb..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/cast.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; -import { GraphQLClient } from '../abilities/graphql-client.ts'; - -export class CellixApiCast implements Cast { - constructor(private readonly apiUrl: string) {} - - prepare(actor: Actor): Actor { - return actor.whoCan(TakeNotes.using(Notepad.empty()), GraphQLClient.at(this.apiUrl)); - } -} diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts b/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts deleted file mode 100644 index fe02a06ae..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/domain-test-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveActorName } from '@ocom-verification/verification-shared/helpers'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts deleted file mode 100644 index 9e919f49d..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -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; - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before(async function (this: IWorld) { - const world = this as IWorld & CellixApiWorld; - - if (!printedSuiteHeader && !isAgent) { - printedSuiteHeader = true; - console.log('\nAPI acceptance tests'); - console.log(' - Community context\n'); - } - - await world.init(); -}); - -After(async function (this: IWorld) { - const world = this as IWorld & CellixApiWorld; - await world.cleanup(); -}); - -AfterAll(async () => { - await stopSharedServers(); -}); 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 deleted file mode 100644 index 799cf064b..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/support/shared-infrastructure.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { GraphQLTestServer, MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; -import { createMockApplicationServicesFactory } from './application-services/index.ts'; - -// Shared infrastructure — persists across scenarios within a single test run -let mongoDBServer: MongoDBTestServer | undefined; -let graphQLServer: GraphQLTestServer | undefined; -let apiUrl: string | undefined; - -interface InfrastructureState { - apiUrl: string | undefined; -} - -export function getState(): InfrastructureState { - return { apiUrl }; -} - -export async function stopAll(): Promise { - if (graphQLServer) { - await graphQLServer.stop(); - graphQLServer = undefined; - } - if (mongoDBServer) { - await mongoDBServer.stop(); - mongoDBServer = undefined; - } - apiUrl = undefined; -} - -async function ensureMongoDBServer(): Promise { - if (mongoDBServer) return mongoDBServer; - - mongoDBServer = new MongoDBTestServer(); - await mongoDBServer.start({ attachMongoose: true }); - return mongoDBServer; -} - -export async function ensureApiServers(): Promise { - if (graphQLServer) return; - - const mongo = await ensureMongoDBServer(); - - const mockApplicationServicesFactory = createMockApplicationServicesFactory(mongo.getServiceMongoose()); - graphQLServer = new GraphQLTestServer(mockApplicationServicesFactory); - 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/shared/test-server-factories.ts b/packages/ocom-verification/acceptance-api/src/shared/test-server-factories.ts new file mode 100644 index 000000000..02c8ed7d7 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/test-server-factories.ts @@ -0,0 +1,36 @@ +import { ApolloGraphQLTestServer } from '@cellix/serenity-framework/servers'; +import type { ApplicationServices } from '@ocom/application-services'; +import { combinedSchema } from '@ocom/graphql'; +import { ServiceMongoose } from '@ocom/service-mongoose'; +import depthLimit from 'graphql-depth-limit'; +import { applyMiddleware } from 'graphql-middleware'; +import { createMockApplicationServicesFactory } from './application-services/index.ts'; + +let mockApplicationServicesFactory: ReturnType | undefined; + +export function createApiMongooseService(connectionString: string, dbName: string): ServiceMongoose { + return new ServiceMongoose(connectionString, { + autoCreate: true, + autoIndex: true, + dbName, + }); +} + +export function createApiGraphQLServer(getMongooseService: () => ServiceMongoose): ApolloGraphQLTestServer<{ applicationServices: ApplicationServices }> { + return new ApolloGraphQLTestServer<{ applicationServices: ApplicationServices }>({ + schema: applyMiddleware(combinedSchema), + validationRules: [depthLimit(10)], + context: async ({ req }) => { + mockApplicationServicesFactory ??= createMockApplicationServicesFactory(getMongooseService()); + const applicationServices = await mockApplicationServicesFactory.forRequest(req.headers.authorization ?? undefined); + if (!applicationServices) { + throw new Error('ApplicationServicesFactory required for test server'); + } + return { applicationServices }; + }, + }); +} + +export function resetApiGraphQLServerFactories(): void { + mockApplicationServicesFactory = undefined; +} diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index 757aebe3b..3c0adb7a8 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -1,31 +1,31 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { engage } from '@serenity-js/core'; -import './shared/support/hooks.ts'; -import { CellixApiCast } from './shared/support/cast.ts'; -import * as infra from './shared/support/shared-infrastructure.ts'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import { createCommunityAbility } from './shared/abilities/create-community.ts'; +import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; +import './shared/cucumber-lifecycle-hooks.ts'; +import * as infra from './shared/shared-infrastructure.ts'; export async function stopSharedServers(): Promise { await infra.stopAll(); } -export class CellixApiWorld extends World { - private apiUrl = ''; - - async init(): Promise { - await infra.ensureApiServers(); - await infra.resetMongoForScenario(); - - const { apiUrl } = infra.getState(); - if (apiUrl) { - this.apiUrl = apiUrl; +export const CellixApiWorld = registerManagedSerenityWorld({ + infrastructure: { + ensureStarted: infra.ensureApiServers, + getState: infra.getState, + resetScenarioState: infra.resetMongoForScenario, + stopAll: infra.stopAll, + }, + validateState: (state) => { + if (!state.apiUrl) { + throw new Error('API acceptance infrastructure did not expose an apiUrl'); } + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [() => createGraphQLClientAbility(state.apiUrl ?? ''), () => createCommunityAbility()], + }), +}); - engage(new CellixApiCast(this.apiUrl)); - } - - async cleanup(): Promise { - // Per-scenario cleanup — extend as needed. - } -} - -setWorldConstructor(CellixApiWorld); +export type CellixApiWorld = InstanceType; diff --git a/packages/ocom-verification/acceptance-ui/cucumber.js b/packages/ocom-verification/acceptance-ui/cucumber.js index e445fafcc..90638142e 100644 --- a/packages/ocom-verification/acceptance-ui/cucumber.js +++ b/packages/ocom-verification/acceptance-ui/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index d9603bcf7..3bac51797 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -5,14 +5,15 @@ "private": true, "type": "module", "scripts": { - "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" + "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js", + "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' c8 cucumber-js", + "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' c8 cucumber-js" }, "dependencies": { "@apollo/client": "^3.13.9", "@cucumber/cucumber": "catalog:", "@dr.pogodin/react-helmet": "^3.0.4", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/console-reporter": "catalog:", "@serenity-js/core": "catalog:", "@serenity-js/cucumber": "catalog:", @@ -32,7 +33,6 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "c8": "^10.1.3", - "jsdom": "^26.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/notes/header-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-ui/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts 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 index 4cffe5960..7f1348c34 100644 --- 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 @@ -1,12 +1,13 @@ +import { mountComponent } from '@cellix/serenity-framework/jsdom/react-render'; 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 { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; import type { CellixUiWorld } from '../../../world.ts'; -import type { HeaderUiNotes } from '../abilities/header-types.ts'; +import type { HeaderUiNotes } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; type Site = 'community' | 'staff'; @@ -77,7 +78,7 @@ When('{word} chooses to sign in', async function (this: CellixUiWorld, _actorNam state.errorCalled = true; }; - const rendered = mountComponent(wrapped); + const rendered = mountComponent(wrapped, { wrapper: wrapOcomComponent() }); this.setHeaderContainer(rendered.container); try { diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts index 5b36010c4..dbf767f8a 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,7 +1,8 @@ -import { HomePage, type UiHomePage } from '@ocom-verification/verification-shared/pages'; -import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { HomePage } from '@ocom-verification/verification-shared/pages'; import { type Activity, Task } from '@serenity-js/core'; +import type { AcceptanceUiHomePage } from '../../../shared/page-contracts.ts'; async function flushAsync(): Promise { await new Promise((resolve) => { @@ -14,7 +15,7 @@ export const ClickHeaderSignIn = (container: HTMLElement) => '#actor clicks the sign-in button on the home page', new TaskStep('#actor clicks the sign-in button', async () => { const adapter = new JsdomPageAdapter(container); - const page: UiHomePage = new HomePage(adapter); + const page: AcceptanceUiHomePage = 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/notes/community-notes.ts similarity index 100% rename from packages/ocom-verification/acceptance-ui/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts index 102d7102f..d232228a1 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-created-flag.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; export const CommunityCreatedFlag = () => Question.about('whether the community form was submitted', (actor) => actor.answer(notes().get('formSubmitted'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts index f1cf03813..61865a446 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; export const CommunityErrorMessage = () => Question.about('the community form error message', (actor) => actor.answer(notes().get('lastValidationError'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts index e0fbdad59..37dcf8f81 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-name.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; export const CommunityName = () => Question.about('the submitted community name', (actor) => actor.answer(notes().get('communityName'))); 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 index ea8a143d9..ee426c518 100644 --- 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 @@ -1,11 +1,15 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { mountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; 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 { CommunityPage } from '@ocom-verification/verification-shared/pages'; 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 { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; import type { CellixUiWorld } from '../../../world.ts'; -import type { CommunityUiNotes } from '../abilities/community-types.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; @@ -19,7 +23,7 @@ Given('{word} is an authenticated community owner', async function (this: Cellix await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? ''), notes().set('lastValidationError', '')); }; - const rendered = mountComponent(); + const rendered = mountComponent(, { wrapper: wrapOcomComponent() }); this.setCommunityContainer(rendered.container); await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), notes().set('lastValidationError', '')); @@ -28,7 +32,7 @@ Given('{word} is an authenticated community owner', async function (this: Cellix 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 }; + const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); }); @@ -36,7 +40,7 @@ When('{word} creates a community with:', async function (this: CellixUiWorld, ac 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 }; + const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); }); @@ -60,11 +64,11 @@ Then('the community name should be {string}', async function (this: CellixUiWorl }); 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 resolvedName = ActorName.resolve(actorName, { defaultName: this.getCommunityActorName() }); const container = this.getCommunityContainer(); const adapter = new JsdomPageAdapter(container); - const page = new CommunityPage(adapter) as UiCommunityPage; + const page = new CommunityPage(adapter) as AcceptanceUiCommunityPage; let storedError: string | undefined; try { 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 320723c11..305801dc3 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,7 +1,8 @@ -import { CommunityPage, type UiCommunityPage } from '@ocom-verification/verification-shared/pages'; -import { JsdomPageAdapter } from '@ocom-verification/verification-shared/pages/jsdom'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; import { type Activity, Task } from '@serenity-js/core'; +import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; async function flushAsync(): Promise { await new Promise((resolve) => { @@ -17,7 +18,7 @@ export const CreateCommunity = (container: HTMLElement, name: string) => `#actor fills community name "${name}" and submits`, new TaskStep(`#actor submits community name "${name}"`, async () => { const adapter = new JsdomPageAdapter(container); - const page: UiCommunityPage = new CommunityPage(adapter); + const page: AcceptanceUiCommunityPage = new CommunityPage(adapter); await page.fillName(name); await page.clickCreate(); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..0c531114f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts @@ -0,0 +1,17 @@ +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { unmountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import { After } from '@cucumber/cucumber'; +import type { CellixUiWorld } from '../world.ts'; + +registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + beforeTimeout: getTimeout('uiInit'), + before: async (world) => { + await world.init(); + }, +}); + +After({ timeout: getTimeout('uiCleanup') }, () => { + unmountComponent(); +}); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts new file mode 100644 index 000000000..10b34268b --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts @@ -0,0 +1,13 @@ +import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import { App, ConfigProvider } from 'antd'; +import React from 'react'; + +interface OcomComponentWrapperOptions { + mocks?: MockedResponse[]; +} + +export function wrapOcomComponent(options?: OcomComponentWrapperOptions) { + return (children: React.ReactElement): React.ReactElement => + React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, children)))); +} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts new file mode 100644 index 000000000..b7512789f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -0,0 +1,5 @@ +import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; + +export type AcceptanceUiHomePage = Pick; + +export type AcceptanceUiCommunityPage = Pick; diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts deleted file mode 100644 index 53124613e..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/cast.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core'; - -/** - * Cast for acceptance-ui tests — each actor gets a Notepad to share - * state between steps. No server abilities needed because UI tests - * render React components directly in jsdom. - */ -export class CellixUiCast extends Cast { - prepare(actor: Actor): Actor { - return actor.whoCan(TakeNotes.using(Notepad.empty())); - } -} diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts deleted file mode 100644 index 212d84a85..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -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.ts'; - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before({ timeout: getTimeout('uiInit') }, async function (this: CellixUiWorld) { - await this.init(); -}); - -After({ timeout: getTimeout('uiCleanup') }, () => { - unmountComponent(); -}); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs deleted file mode 100644 index 472a0c864..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs +++ /dev/null @@ -1,34 +0,0 @@ -/** - * ESM loader hooks that intercept CSS, image, and other non-JS imports so - * they resolve to empty modules instead of throwing in Node.js. - * - * Usage: `NODE_OPTIONS='--import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js` - */ - -const ASSET_RE = /\.(css|less|scss|sass|svg|png|jpe?g|gif|webp|woff2?|ttf|eot|ico)$/i; - -/** - * @param {string} specifier - * @param {{ parentURL?: string }} context - * @param {Function} nextResolve - */ -export async function resolve(specifier, context, nextResolve) { - if (ASSET_RE.test(specifier)) { - return { - shortCircuit: true, - url: `data:text/javascript,export default ''`, - }; - } - - // Redirect antd/es/* to antd/lib/* for CJS/ESM compatibility in Node.js - if (specifier.includes('antd/es/')) { - const redirected = specifier.replace('antd/es/', 'antd/lib/'); - try { - return await nextResolve(redirected, context); - } catch { - // fall through to default - } - } - - return nextResolve(specifier, context); -} 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 deleted file mode 100644 index d48b7eee8..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/react-render.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/register-asset-loader.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts deleted file mode 100644 index 917bda93a..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Registers the asset-loader ESM hooks so CSS/image imports resolve - * without errors in Node.js. - * - * Use via NODE_OPTIONS: `NODE_OPTIONS='--import ./src/shared/support/ui/register-asset-loader.ts'` - * or by adding `--import` to the cucumber-js invocation. - */ -import { register } from 'node:module'; - -register(new URL('./asset-loader-hooks.mjs', import.meta.url).href, import.meta.url); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts b/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts deleted file mode 100644 index 67f8ee07d..000000000 --- a/packages/ocom-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Side-effect import that initialises the jsdom environment. - * Consumed by cucumber.js --import or NODE_OPTIONS --import. - */ -import './jsdom-setup.ts'; 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 2107af436..cc39388de 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -3,7 +3,7 @@ * Cucumber imports this file, which then loads all context-specific step definitions. */ -import '../shared/support/ui/setup-jsdom.ts'; -import '../shared/support/hooks.ts'; +import '@cellix/serenity-framework/jsdom/setup'; +import '../shared/cucumber-lifecycle-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 79e0fd932..986ea574d 100644 --- a/packages/ocom-verification/acceptance-ui/src/world.ts +++ b/packages/ocom-verification/acceptance-ui/src/world.ts @@ -1,17 +1,22 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { type Cast, serenity } from '@serenity-js/core'; -import { CellixUiCast } from './shared/support/cast.ts'; - -export class CellixUiWorld extends World { - private cast!: Cast; +import { ManagedSerenityWorld, type ManagedSerenityWorldOptions } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import { type IWorldOptions, setWorldConstructor } from '@cucumber/cucumber'; + +const uiWorldConfig: ManagedSerenityWorldOptions> = { + infrastructure: { + ensureStarted: () => Promise.resolve(), + getState: () => ({}), + }, + createCast: () => new SerenityCast({ useNotepad: true }), +}; + +export class CellixUiWorld extends ManagedSerenityWorld> { 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(); + constructor(options: IWorldOptions) { + super(options, uiWorldConfig); } setCommunityContainer(container: HTMLElement): void { diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 026eb254a..a1ea4a843 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -13,5 +13,5 @@ "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/jsdom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] } diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index e548d91e5..38c29160d 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/step-definitions/index.ts'], - format: [...(isAgent ? ['../verification-shared/src/formatters/agent-formatter.ts'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], + format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], formatOptions: { snippetInterface: 'async-await', }, diff --git a/packages/ocom-verification/e2e-tests/package.json b/packages/ocom-verification/e2e-tests/package.json index d0653152b..7cd34c0a0 100644 --- a/packages/ocom-verification/e2e-tests/package.json +++ b/packages/ocom-verification/e2e-tests/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@cucumber/cucumber": "catalog:", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/assertions": "catalog:", "@serenity-js/console-reporter": "catalog:", "@serenity-js/core": "catalog:", diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/notes/header-notes.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/contexts/authentication/abilities/header-types.ts rename to packages/ocom-verification/e2e-tests/src/contexts/authentication/notes/header-notes.ts diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts index 998ec4dcd..d03d4f5b0 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -1,9 +1,9 @@ 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'; +import * as infra from '../../../shared/shared-infrastructure.ts'; import type { CellixE2EWorld } from '../../../world.ts'; -import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../notes/header-notes.ts'; import { ClickHeaderSignIn } from '../tasks/click-header-sign-in.ts'; interface HeaderE2EState { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts index bdf690de1..bdbc54a93 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,9 +1,10 @@ -import { type E2EHomePage, HomePage } from '@ocom-verification/verification-shared/pages'; -import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { HomePage } from '@ocom-verification/verification-shared/pages'; import { type Activity, type Actor, notes, Task } from '@serenity-js/core'; import type { Page } from 'playwright'; -import type { HeaderE2ENotes, HeaderE2ESite } from '../abilities/header-types.ts'; +import type { E2EHomePage } from '../../../shared/page-contracts.ts'; +import type { HeaderE2ENotes, HeaderE2ESite } from '../notes/header-notes.ts'; const portalCredentials: Record = { community: { username: 'test@example.com', password: 'password' }, diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-types.ts rename to packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts index f89c53ceb..89b92e21a 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-created-flag.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityCreatedFlag = () => Question.about('whether the community was created', (actor) => actor.answer(notes().get('communityCreated'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts index edc11de1a..9d0fa77d8 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-error-message.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityErrorMessage = () => Question.about('the community error message', (actor) => actor.answer(notes().get('errorMessage'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts index 8d4cf0a69..9858c66cb 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-name.ts @@ -1,4 +1,4 @@ import { notes, Question } from '@serenity-js/core'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; export const CommunityName = () => Question.about('the name of the created community', (actor) => actor.answer(notes().get('communityName'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts index a8d602956..a13835647 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts @@ -1,8 +1,10 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; 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 { OAuth2Login } from '../../../shared/support/oauth2-login.ts'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; @@ -13,13 +15,13 @@ let lastActorName = actors.CommunityOwner.name; Given('{word} is an authenticated community owner', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - await actor.attemptsTo(OAuth2Login(actors.CommunityOwner.email)); + await actor.attemptsTo(LogInWithOAuth2(actors.CommunityOwner.email)); }); When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); + const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; await actor.attemptsTo(CreateCommunity(name)); @@ -28,7 +30,7 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); - const details = dataTable.rowsHash(); + const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; try { @@ -58,7 +60,7 @@ Then('the community name should be {string}', async (expectedName: string) => { }); 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 resolvedName = ActorName.resolve(actorName, { defaultName: lastActorName }); const actor = actorCalled(resolvedName); const errorMessage = await actor.answer(CommunityErrorMessage()); 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 1f67af90e..fa2dbab08 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,10 +1,11 @@ -import { CommunityPage, type E2ECommunityPage } from '@ocom-verification/verification-shared/pages'; -import { PlaywrightPageAdapter } from '@ocom-verification/verification-shared/pages/playwright'; -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; import { type Activity, type Actor, notes, Task, the } from '@serenity-js/core'; import type { Response } from 'playwright'; -import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import type { CommunityE2ENotes } from '../abilities/community-types.ts'; +import type { E2ECommunityPage } from '../../../shared/page-contracts.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; const createCommunityOperationName = 'AccountsCommunityCreateContainerCommunityCreate'; const communityListOperationName = 'AccountsCommunityListContainerCommunitiesForCurrentEndUser'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts new file mode 100644 index 000000000..2425aa4f9 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts @@ -0,0 +1,108 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { Ability, type Activity, type Actor, Task, the } from '@serenity-js/core'; +import type { Page } from 'playwright'; + +/** Credentials used by the E2E OAuth2 login flow. */ +export interface OAuth2Credentials { + /** Email or username submitted to the mock OAuth2 login form. */ + email: string; + + /** Password submitted to the mock OAuth2 login form. */ + password: string; +} + +/** Options that configure the E2E OAuth2 login ability. */ +export interface OAuth2LoginOptions { + /** Protected route used to trigger the OIDC redirect flow. */ + protectedPath: string; +} + +/** + * URL predicate that resolves once the OIDC redirect chain has settled — + * i.e. we are no longer on the mock-auth hostname or the /auth-redirect + * callback path. + */ +const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'); + +/** + * Authenticates the browser session via the OIDC auto-redirect flow. + * + * The app uses RequireAuth + react-oidc-context. When an unauthenticated + * user hits a protected route, RequireAuth calls `signinRedirect()` which + * navigates to the mock OAuth2 server's `/authorize` endpoint. The mock + * server redirects to `/login` (since userStore is configured). This + * function fills in the test user credentials and submits the form. + */ +export async function performOAuth2Login(page: Page, credentials: OAuth2Credentials, protectedPath: string): Promise { + // Navigate to a protected route to trigger the OIDC signinRedirect flow. + try { + await page.goto(protectedPath, { + waitUntil: 'networkidle', + timeout: 60_000, + }); + } catch { + // Navigation may be interrupted by OIDC redirect — this is expected + } + + // Wait for redirects to settle on either the login page or the app + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => undefined); + + // If the mock OAuth2 login form is shown, fill credentials and submit. + // CommunityOwner is defined in mock-oidc.users.json with password "password". + if (page.url().includes('/login')) { + await page.fill('input[name="username"]', credentials.email); + await page.fill('input[name="password"]', credentials.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'); +} + +/** Serenity ability that authenticates an E2E actor through the OCOM OAuth2 flow. */ +export class OAuth2Login extends Ability { + /** + * @param options Route and flow options for the OAuth2 login ability. + */ + constructor(private readonly options: OAuth2LoginOptions) { + super(); + } + + /** + * Create an OAuth2 login ability for the supplied protected route. + * + * @param protectedPath Protected route used to trigger the OIDC redirect flow. + */ + static throughProtectedRoute(protectedPath: string): OAuth2Login { + return new OAuth2Login({ protectedPath }); + } + + /** + * Authenticate the actor's current browser page. + * + * @param actor Actor that has the `BrowseTheWeb` ability. + * @param credentials Credentials submitted to the mock OAuth2 form. + */ + async authenticate(actor: Actor, credentials: OAuth2Credentials): Promise { + const { page } = BrowseTheWeb.withActor(actor); + await performOAuth2Login(page, credentials, this.options.protectedPath); + } +} + +/** + * Screenplay Task — confirms the actor is authenticated. + * + * The browser context is pre-authenticated by {@link performOAuth2Login} + * during server setup. This task navigates to a protected route and + * verifies the page loads without being kicked to the auth provider. + */ +export const LogInWithOAuth2 = (email = actors.CommunityOwner.email, password = 'password') => + Task.where( + the`#actor logs in via OAuth2`, + new TaskStep('#actor confirms the OAuth2 session is active', async (actor) => { + await OAuth2Login.as(actor as Actor).authenticate(actor as Actor, { email, password }); + }) as Activity, + ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/e2e-tests/src/shared/cucumber-lifecycle-hooks.ts new file mode 100644 index 000000000..89b7424b3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/cucumber-lifecycle-hooks.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; +import { registerScreenshotOnFailureHook } from '@cellix/serenity-framework/cucumber/screenshot'; +import { getTimeout } from '@cellix/serenity-framework/settings'; +import type { IWorld } from '@cucumber/cucumber'; +import { type CellixE2EWorld, stopSharedServers } from '../world.ts'; + +const currentDir = fileURLToPath(new URL('.', import.meta.url)); + +registerWorldLifecycleHooks({ + scenarioTimeout: getTimeout('scenario'), + before: async (world) => { + await world.init(); + }, + after: async (world) => { + await world.cleanup(); + }, + afterAll: stopSharedServers, +}); + +registerScreenshotOnFailureHook({ + reportsDir: path.resolve(currentDir, '..', '..', '..', 'reports', 'screenshots'), +}); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts similarity index 86% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts index d8ea7b022..9a5e789f7 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/app-paths.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/app-paths.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const currentDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(currentDir, '../../../../../../..'); +const workspaceRoot = resolve(currentDir, '../../../../../..'); export const appPaths = { apiDir: resolve(workspaceRoot, 'apps/api'), diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/dev-script.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/dev-script.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/dev-script.ts diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts similarity index 89% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts index 42c7f7827..9c5f978f9 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/resolve-portless.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/resolve-portless.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const currentDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(currentDir, '../../../../../../..'); +const workspaceRoot = resolve(currentDir, '../../../../../..'); let resolvedPath: string | undefined; diff --git a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts new file mode 100644 index 000000000..cfb488e4c --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getPortlessPath } from './resolve-portless.ts'; + +let proxyInitialized = false; + +loadE2EEnvDefaults(); + +export type OcomPortlessHostKey = 'api' | 'mockAuth' | 'uiCommunity' | 'uiStaff'; + +export function getHostnames(): Record { + const hostnames = resolvePortlessHostnames({ + keys: { + api: 'VITE_COMMON_API_ENDPOINT', + mockAuth: 'VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', + uiCommunity: 'VITE_APP_UI_COMMUNITY_BASE_URL', + uiStaff: 'VITE_APP_UI_STAFF_AAD_REDIRECT_URI', + }, + }); + + return { + ...hostnames, + docs: `docs.${hostnames.uiCommunity}`, + }; +} + +const hostnames = getHostnames(); + +export const mockOidcAudience = 'mock-client'; +export const mockOidcIssuer = buildUrl(hostnames.mockAuth, '/community'); +export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; +export const mockStaffOidcIssuer = buildUrl(hostnames.mockAuth, '/staff'); + +/** + * Ensure the portless proxy is running for the PR's worktree-scoped hostnames. + */ +export function initTestEnvironment() { + if (proxyInitialized) return; + + execFileSync(getPortlessPath(), ['prune'], { + timeout: 10_000, + stdio: 'pipe', + }); + execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { + timeout: 15_000, + stdio: 'pipe', + }); + + proxyInitialized = true; +} + +export function buildUrl(hostname: string, path = ''): string { + return `https://${hostname}:1355${path}`; +} + +export function cleanupTestEnvironment(): void { + proxyInitialized = false; +} + +function loadE2EEnvDefaults(): void { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const workspaceRoot = resolve(currentDir, '../../../../../..'); + for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { + if (!existsSync(filePath)) continue; + for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + const key = trimmed.slice(0, idx); + process.env[key] ??= trimmed.slice(idx + 1); + } + } +} + +interface ResolvePortlessHostnamesOptions { + keys: Record; + env?: NodeJS.ProcessEnv; + worktreeName?: string; +} + +function resolvePortlessHostnames(options: ResolvePortlessHostnamesOptions): Record { + const env = options.env ?? process.env; + const worktreeName = options.worktreeName ?? env['WORKTREE_NAME'] ?? ''; + const hostnames = {} as Record; + + for (const [logicalName, envName] of Object.entries(options.keys) as Array<[TKey, string]>) { + hostnames[logicalName] = applyWorktreeSuffix(requireHostname(envName, env), worktreeName); + } + + return hostnames; +} + +function applyWorktreeSuffix(hostname: string, worktreeName: string): string { + if (!worktreeName) return hostname; + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +function requireHostname(key: string, env: NodeJS.ProcessEnv): string { + const url = env[key] ?? ''; + try { + return new URL(url).hostname; + } catch { + throw new Error(`e2e test environment: required env var ${key} is missing or invalid`); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/worktree-ports.ts similarity index 100% rename from packages/ocom-verification/e2e-tests/src/shared/support/servers/worktree-ports.ts rename to packages/ocom-verification/e2e-tests/src/shared/environment/worktree-ports.ts diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts new file mode 100644 index 000000000..677ab56e7 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -0,0 +1,5 @@ +import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; + +export type E2EHomePage = Pick; + +export type E2ECommunityPage = Pick; diff --git a/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts new file mode 100644 index 000000000..318008c19 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts @@ -0,0 +1,60 @@ +import { E2EInfrastructure, type E2EInfrastructureState } from '@cellix/serenity-framework/infrastructure/e2e'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; +import { getMongoPort } from './environment/worktree-ports.ts'; +import { cleanupTestEnvironment, createCommunityUiPortalServer, createStaffUiPortalServer, createTestApiServer, createTestAzuriteServer, createTestOAuth2Server, initTestEnvironment } from './test-server-factories.ts'; + +const apiDbName = 'owner-community'; + +const infrastructure = E2EInfrastructure.using({ + authServer: createTestOAuth2Server(), + azuriteServer: createTestAzuriteServer(), + browserContextOptions: (state) => { + const communityBaseUrl = state.uiPortalBaseUrls['community']; + if (!communityBaseUrl) { + throw new Error('Community UI portal URL was not initialized'); + } + + return { + baseURL: communityBaseUrl, + ignoreHTTPSErrors: true, + }; + }, + cleanupEnvironment: cleanupTestEnvironment, + createApiServer: ({ getMongoConnectionString }) => createTestApiServer(getMongoConnectionString), + mongoServer: { + dbName: apiDbName, + port: getMongoPort(), + replSetName: 'globaldb', + seedData: seedDatabase, + }, + setupEnvironment: initTestEnvironment, +}) + .addUiPortal('community', createCommunityUiPortalServer()) + .addUiPortal('staff', createStaffUiPortalServer()); + +interface InfrastructureState extends E2EInfrastructureState { + staffBaseUrl: string | undefined; + communityBaseUrl: string | undefined; +} + +export function getState(): InfrastructureState { + const state = infrastructure.getState(); + return { + ...state, + communityBaseUrl: state.uiPortalBaseUrls['community'], + staffBaseUrl: state.uiPortalBaseUrls['staff'], + }; +} + +export async function resetScenarioState(): Promise { + await infrastructure.resetScenarioState(); +} + +export async function stopAll(): Promise { + await infrastructure.stopAll(); +} + +export async function ensureE2EServers(): Promise { + infrastructure.registerProcessShutdownHandlers(); + await infrastructure.ensureStarted(); +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts b/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts deleted file mode 100644 index a88926e1c..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/cast.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; -import type { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - -export class CellixE2ECast implements Cast { - constructor(private readonly browseTheWeb?: BrowseTheWeb) {} - - prepare(actor: Actor): Actor { - if (!this.browseTheWeb) { - throw new Error('E2E tests require a browser'); - } - return actor.whoCan(TakeNotes.using(Notepad.empty()), this.browseTheWeb); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts b/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts deleted file mode 100644 index 477877fc7..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/hooks.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from 'node:fs'; -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)); - -/** Default scenario timeout from centralized configuration */ -setDefaultTimeout(getTimeout('scenario')); - -Before(async function (this: IWorld) { - const world = this as IWorld & CellixE2EWorld; - await world.init(); -}); - -After(async function (this: IWorld, { result, pickle }: ITestCaseHookParameter) { - const world = this as IWorld & CellixE2EWorld; - - if (result?.status === Status.FAILED) { - try { - const browseTheWeb = BrowseTheWeb.current(); - if (browseTheWeb) { - const reportsDir = path.resolve(currentDir, '..', '..', '..', 'reports', 'screenshots'); - fs.mkdirSync(reportsDir, { recursive: true }); - - const safeName = pickle.name.replaceAll(/[^a-zA-Z0-9-_]/g, '_').slice(0, 80); - const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); - const screenshotPath = path.join(reportsDir, `${safeName}-${timestamp}.png`); - - await browseTheWeb.page.screenshot({ path: screenshotPath, fullPage: true }); - this.attach(fs.readFileSync(screenshotPath), 'image/png'); - } - } catch { - /* Screenshot capture is best-effort */ - } - } - - await world.cleanup(); -}); - -AfterAll(async () => { - await stopSharedServers(); -}); 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 deleted file mode 100644 index 92911f16d..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TaskStep } from '@ocom-verification/verification-shared/serenity'; -import { actors } from '@ocom-verification/verification-shared/test-data'; -import { type Activity, type Actor, Task, the } from '@serenity-js/core'; -import type { Page } from 'playwright'; -import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - -/** - * URL predicate that resolves once the OIDC redirect chain has settled — - * i.e. we are no longer on the mock-auth hostname or the /auth-redirect - * callback path. - */ -const isPostAuthUrl = (url: URL) => !url.hostname.includes('mock-auth') && !url.pathname.includes('auth-redirect'); - -/** - * Authenticates the browser session via the OIDC auto-redirect flow. - * - * The app uses RequireAuth + react-oidc-context. When an unauthenticated - * user hits a protected route, RequireAuth calls `signinRedirect()` which - * navigates to the mock OAuth2 server's `/authorize` endpoint. The mock - * server redirects to `/login` (since userStore is configured). This - * function fills in the test user credentials and submits the form. - */ -export async function performOAuth2Login(page: Page): Promise { - // Navigate to a protected route to trigger the OIDC signinRedirect flow. - try { - await page.goto('/community/accounts', { - waitUntil: 'networkidle', - timeout: 60_000, - }); - } catch { - // Navigation may be interrupted by OIDC redirect — this is expected - } - - // Wait for redirects to settle on either the login page or the app - await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => undefined); - - // If the mock OAuth2 login form is shown, fill credentials and submit. - // CommunityOwner is defined in mock-oidc.users.json with password "password". - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', actors.CommunityOwner.email); - 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'); -} - -/** - * Screenplay Task — confirms the actor is authenticated. - * - * The browser context is pre-authenticated by {@link performOAuth2Login} - * during server setup. This task navigates to a protected route and - * verifies the page loads without being kicked to the auth provider. - */ -export const OAuth2Login = (_email?: string, _password?: string) => - Task.where( - the`#actor logs in via OAuth2`, - new TaskStep('#actor confirms the OAuth2 session is active', async (actor) => { - const { page } = BrowseTheWeb.withActor(actor as Actor); - - // Session tokens live in sessionStorage from pre-auth. - try { - await page.goto('/community/accounts', { - waitUntil: 'networkidle', - timeout: 30_000, - }); - } catch { - // Navigation may be interrupted by OIDC redirect on first access - } - - if (page.url().includes('/login')) { - await page.fill('input[name="username"]', actors.CommunityOwner.email); - await page.fill('input[name="password"]', 'password'); - await page.click('button[type="submit"]'); - } - - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); - }) as Activity, - ); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts deleted file mode 100644 index 1ce570cd1..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/child-process-env.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function spawnEnv(overrides: Record = {}): NodeJS.ProcessEnv { - const { NODE_OPTIONS: _ignored, ...baseEnv } = process.env; - return { ...baseEnv, ...overrides }; -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts deleted file mode 100644 index 3f7b41971..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; -export { PortlessServer } from './portless-server.ts'; -export { TestApiServer } from './test-api-server.ts'; -export { TestAzuriteServer } from './test-azurite-server.ts'; -export { TestCommunityViteServer } from './test-community-vite-server.ts'; -export { - buildUrl, - cleanupTestEnvironment, - initTestEnvironment, - mockOidcAudience, - mockOidcEndpoint, - mockOidcIssuer, - mockStaffOidcIssuer, - setMongoConnectionString, -} from './test-environment.ts'; -export { TestOAuth2Server } from './test-oauth2-server.ts'; -export { TestStaffViteServer } from './test-staff-vite-server.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts deleted file mode 100644 index feb84d100..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/portless-server.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import type { TestServer } from '@ocom-verification/verification-shared/servers'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { spawnEnv } from './child-process-env.ts'; -import { getPortlessPath } from './resolve-portless.ts'; - -/** - * Abstract base class for portless-proxied servers. - * Subclasses define the hostname, command, ready marker, probe URL, and working directory. - */ -export abstract class PortlessServer implements TestServer { - private process: ChildProcess | null = null; - 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 spawnArgs(): string[]; - protected abstract get cwd(): string; - - protected get executable(): string { - return getPortlessPath(); - } - - protected get probeRequestInit(): RequestInit { - return {}; - } - - protected get extraEnv(): Record { - return {}; - } - - protected get startupTimeoutMs(): number { - return getTimeout('serverStartup'); - } - - protected isProbeHealthy(response: Response): boolean | Promise { - return response.ok; - } - - isAlreadyRunning(): Promise { - return this.isProbeReadyWithin(getTimeout('healthProbe')); - } - - abstract getUrl(): string; - - async start(): Promise { - if (this.process || this.startedByUs) return; - if (await this.isAlreadyRunning()) return; - - this.process = spawn(this.executable, this.spawnArgs, { - cwd: this.cwd, - env: spawnEnv(this.extraEnv), - detached: this.useDetachedProcessGroup, - stdio: ['ignore', 'pipe', 'pipe'], - }); - this.startedByUs = true; - - await this.waitForReady(); - } - - async stop(): Promise { - if (!this.process || !this.startedByUs) return; - - const proc = this.process; - this.process = null; - this.startedByUs = false; - - this.killProcess(proc, 'SIGINT'); - - const shutdownTimeout = getTimeout('serverShutdown'); - await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.killProcess(proc, 'SIGKILL'); - resolve(); - }, shutdownTimeout); - - proc.on('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - - isRunning(): boolean { - return this.process !== null; - } - - private waitForReady(): Promise { - return new Promise((resolve, reject) => { - const proc = this.process; - if (!proc) { - reject(new Error(`${this.serverName} process not started`)); - return; - } - - const startupTimeout = this.startupTimeoutMs; - const startupDeadline = Date.now() + startupTimeout; - const timeout = setTimeout(() => { - 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(startupDeadline, startupTimeout) - .then(() => { - clearTimeout(timeout); - resolve(); - }) - .catch((error: unknown) => { - clearTimeout(timeout); - reject(error); - }); - }; - - proc.stdout?.on('data', (data: Buffer) => { - const text = data.toString(); - if (text.includes(this.readyMarker)) { - resolveWhenReachable(); - } - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderrOutput += data.toString(); - }); - - proc.on('error', (err: Error) => { - clearTimeout(timeout); - this.process = null; - this.startedByUs = false; - reject(new Error(`${this.serverName} failed to start: ${err.message}`)); - }); - - proc.on('exit', (code, signal) => { - if (ready) return; - clearTimeout(timeout); - this.process = null; - this.startedByUs = false; - reject(new Error(`${this.serverName} exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); - }); - }); - } - - private async waitForProbeReady(startupDeadline: number, startupTimeout: number): Promise { - const probeInterval = getTimeout('healthProbeInterval'); - const timeoutError = () => new Error(`${this.serverName} did not become healthy within ${startupTimeout}ms`); - - while (true) { - const remainingMs = startupDeadline - Date.now(); - if (remainingMs <= 0) { - throw timeoutError(); - } - - if (await this.isProbeReadyWithin(Math.min(getTimeout('healthProbe'), remainingMs))) { - return; - } - - const retryDelay = Math.min(probeInterval, startupDeadline - Date.now()); - if (retryDelay <= 0) { - throw timeoutError(); - } - - await new Promise((resolve) => setTimeout(resolve, retryDelay)); - } - } - - private async isProbeReadyWithin(timeoutMs: number): Promise { - let timeout: ReturnType | undefined; - try { - const controller = new AbortController(); - timeout = setTimeout(() => controller.abort(), timeoutMs); - const response = await fetch(this.probeUrl, { ...this.probeRequestInit, signal: controller.signal }); - return await this.isProbeHealthy(response); - } catch { - return false; - } finally { - if (timeout) clearTimeout(timeout); - } - } - - private killProcess(proc: ChildProcess, signal: NodeJS.Signals): void { - if (this.useDetachedProcessGroup && proc.pid) { - try { - 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 deleted file mode 100644 index ea701b158..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-api-server.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames, getMongoConnectionString } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Spawns the api e2e dev server through the PR's portless/worktree path. - */ -export class TestApiServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - 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 override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.apiDir; - } - - protected override get extraEnv() { - return e2eEnv({ - COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), - }); - } - - getUrl(): string { - return buildUrl(hostnames.api, '/api/graphql'); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts deleted file mode 100644 index 9f3c8f28b..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-azurite-server.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { join } from 'node:path'; -import type { TestServer } from '@ocom-verification/verification-shared/servers'; -import { getTimeout } from '@ocom-verification/verification-shared/settings'; -import { appPaths } from './app-paths.ts'; -import { spawnEnv } from './child-process-env.ts'; -import { getAzuritePorts } from './worktree-ports.ts'; - -/** - * Starts Azurite via apps/api/start-azurite.mjs. - * If ports are already bound (EADDRINUSE), we treat that as an existing - * reusable instance for this worktree. - */ -export class TestAzuriteServer implements TestServer { - private process: ChildProcess | null = null; - private startedByUs = false; - private readonly useDetachedProcessGroup = process.platform !== 'win32'; - - private get blobPort(): number { - return getAzuritePorts().blob; - } - - async start(): Promise { - if (this.process || this.startedByUs) return; - - const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); - const { PATH: pathValue = '' } = process.env; - - this.process = spawn('node', ['start-azurite.mjs'], { - cwd: appPaths.apiDir, - env: spawnEnv({ PATH: `${binDir}:${pathValue}` }), - detached: this.useDetachedProcessGroup, - stdio: ['ignore', 'pipe', 'pipe'], - }); - this.startedByUs = true; - - await this.waitForStartedMarker(); - } - - async stop(): Promise { - if (!this.process || !this.startedByUs) return; - - const proc = this.process; - this.process = null; - this.startedByUs = false; - - killProcess(proc, 'SIGTERM', this.useDetachedProcessGroup); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - killProcess(proc, 'SIGKILL', this.useDetachedProcessGroup); - resolve(); - }, getTimeout('serverShutdown')); - - proc.on('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - - isRunning(): boolean { - return this.process !== null; - } - - getUrl(): string { - return `http://127.0.0.1:${this.blobPort}`; - } - - private waitForStartedMarker(): Promise { - return new Promise((resolve, reject) => { - const proc = this.process; - if (!proc) { - reject(new Error('TestAzuriteServer process not started')); - return; - } - - const timeout = setTimeout(() => { - reject(new Error(`TestAzuriteServer did not emit start marker within ${getTimeout('serverStartup')}ms`)); - }, getTimeout('serverStartup')); - - let stderrOutput = ''; - - proc.stdout?.on('data', (data: Buffer) => { - if (data.toString().includes('[azurite] started')) { - clearTimeout(timeout); - resolve(); - } - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderrOutput += data.toString(); - }); - - proc.on('error', (error: Error) => { - clearTimeout(timeout); - reject(new Error(`TestAzuriteServer failed to start: ${error.message}`)); - }); - - proc.on('exit', (code, signal) => { - clearTimeout(timeout); - if (stderrOutput.includes('EADDRINUSE')) { - this.process = null; - this.startedByUs = false; - resolve(); - return; - } - reject(new Error(`TestAzuriteServer exited unexpectedly (code: ${code}, signal: ${signal}). stderr: ${stderrOutput.slice(-2000)}`)); - }); - }); - } -} - -function killProcess(proc: ChildProcess, signal: NodeJS.Signals, useGroup: boolean): void { - if (useGroup && proc.pid) { - try { - process.kill(-proc.pid, signal); - return; - } catch { - /* Fall back to killing the direct child. */ - } - } - proc.kill(signal); -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts deleted file mode 100644 index fb567e0aa..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Starts the community portal Vite dev server via portless. - */ -export class TestCommunityViteServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - protected get readyMarker() { - return 'ready in'; - } - - protected get serverName() { - return 'TestCommunityViteServer'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.uiCommunityDir; - } - - protected override get extraEnv() { - return e2eEnv({ - BROWSER: 'none', - NODE_ENV: 'development', - }); - } - - getUrl(): string { - return buildUrl(hostnames.uiCommunity); - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts deleted file mode 100644 index f93859c16..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-environment.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildPortlessUrl, getHostnames } from '@ocom-verification/verification-shared/settings'; -import { getPortlessPath } from './resolve-portless.ts'; - -let proxyInitialized = false; -let mongoConnectionString: string | undefined; - -loadE2EEnvDefaults(); - -const hostnames = getHostnames(); - -export const mockOidcAudience = 'mock-client'; -export const mockOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/community'); -export const mockOidcEndpoint = `${mockOidcIssuer}/.well-known/jwks.json`; -export const mockStaffOidcIssuer = buildPortlessUrl(hostnames.mockAuth, '/staff'); - -/** - * Ensure the portless proxy is running for the PR's worktree-scoped hostnames. - */ -export function initTestEnvironment() { - if (proxyInitialized) return; - - execFileSync(getPortlessPath(), ['prune'], { - timeout: 10_000, - stdio: 'pipe', - }); - execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { - timeout: 15_000, - stdio: 'pipe', - }); - - proxyInitialized = true; -} - -export { buildPortlessUrl as buildUrl, getHostnames }; - -export function setMongoConnectionString(connStr: string): void { - mongoConnectionString = connStr; -} - -export function getMongoConnectionString(): string { - if (!mongoConnectionString) { - throw new Error('MongoDB connection string not set - call setMongoConnectionString() first'); - } - return mongoConnectionString; -} - -export function cleanupTestEnvironment(): void { - proxyInitialized = false; - mongoConnectionString = undefined; -} - -function loadE2EEnvDefaults(): void { - const currentDir = dirname(fileURLToPath(import.meta.url)); - const workspaceRoot = resolve(currentDir, '../../../../../../..'); - for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { - if (!existsSync(filePath)) continue; - for (const line of readFileSync(filePath, 'utf-8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const idx = trimmed.indexOf('='); - if (idx === -1) continue; - const key = trimmed.slice(0, idx); - process.env[key] ??= trimmed.slice(idx + 1); - } - } -} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts deleted file mode 100644 index 424d028e3..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { mockOidcEndpoint, mockOidcIssuer } from './test-environment.ts'; - -/** - * Starts the mock OAuth2/OIDC server via portless. - */ -export class TestOAuth2Server extends PortlessServer { - protected get probeUrl() { - return mockOidcEndpoint; - } - - protected get readyMarker() { - return 'Registered OIDC config'; - } - - protected get serverName() { - return 'TestOAuth2Server'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.oauth2MockDir; - } - - getUrl(): string { - 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 deleted file mode 100644 index 8eab2f068..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { appPaths } from './app-paths.ts'; -import { e2eEnv, getPortlessDevScript } from './dev-script.ts'; -import { PortlessServer } from './portless-server.ts'; -import { buildUrl, getHostnames } from './test-environment.ts'; - -const hostnames = getHostnames(); - -/** - * Starts the staff portal Vite dev server via portless. - */ -export class TestStaffViteServer extends PortlessServer { - protected get probeUrl() { - return this.getUrl(); - } - - protected get readyMarker() { - return 'ready in'; - } - - protected get serverName() { - return 'TestStaffViteServer'; - } - - protected override get executable() { - return 'pnpm'; - } - - protected get spawnArgs() { - return ['run', getPortlessDevScript()]; - } - - protected get cwd() { - return appPaths.uiStaffDir; - } - - protected override get extraEnv() { - return e2eEnv({ - BROWSER: 'none', - NODE_ENV: 'development', - }); - } - - getUrl(): string { - return buildUrl(hostnames.uiStaff); - } -} 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 deleted file mode 100644 index 472301f78..000000000 --- a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ /dev/null @@ -1,190 +0,0 @@ -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, TestAzuriteServer, TestCommunityViteServer, TestOAuth2Server, TestStaffViteServer } from './servers/index.ts'; -import { getMongoPort } from './servers/worktree-ports.ts'; - -const apiDbName = 'owner-community'; - -let mongoDBServer: MongoDBTestServer | undefined; -let azuriteServer: TestAzuriteServer | undefined; -let oauth2Server: TestOAuth2Server | undefined; -let apiServer: TestApiServer | undefined; -let communityViteServer: TestCommunityViteServer | undefined; -let staffViteServer: TestStaffViteServer | undefined; -let apiUrl: 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; - browseTheWeb: BrowseTheWeb | undefined; - staffBaseUrl: string | undefined; - communityBaseUrl: string | undefined; - browser: Browser | undefined; -} - -export function getState(): InfrastructureState { - return { apiUrl, browseTheWeb, staffBaseUrl: staffViteServer?.getUrl(), communityBaseUrl: browserBaseUrl, browser }; -} - -/** - * Resets mutable state between scenarios without restarting servers. - */ -export async function resetScenarioState(): Promise { - if (mongoDBServer?.isRunning()) { - await mongoDBServer.resetForScenario(); - } -} - -export async function stopAll(): Promise { - if (browseTheWeb) { - await browseTheWeb.close().catch(() => undefined); - browseTheWeb = undefined; - } else if (authenticatedBrowserContext) { - await authenticatedBrowserContext.close().catch(() => undefined); - } - authenticatedBrowserContext = undefined; - - if (browser) { - await browser.close().catch(() => undefined); - browser = 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); - apiServer = undefined; - } - if (oauth2Server) { - await oauth2Server.stop().catch(() => undefined); - oauth2Server = undefined; - } - if (mongoDBServer) { - await mongoDBServer.stop().catch(() => undefined); - mongoDBServer = undefined; - } - if (azuriteServer) { - await azuriteServer.stop().catch(() => undefined); - azuriteServer = undefined; - } - - apiUrl = undefined; - browserBaseUrl = undefined; - cleanupTestEnvironment(); -} - -export async function ensureE2EServers(): Promise { - initTestEnvironment(); - registerShutdownHandlers(); - - mongoDBServer ??= new MongoDBTestServer(); - azuriteServer ??= new TestAzuriteServer(); - oauth2Server ??= new TestOAuth2Server(); - - const mongo = mongoDBServer; - const azurite = azuriteServer; - const oauth2 = oauth2Server; - const phase1: Promise[] = []; - - if (!mongo.isRunning()) { - phase1.push( - mongo.start({ dbName: apiDbName, port: getMongoPort() }).then(() => { - setMongoConnectionString(mongo.getConnectionString()); - }), - ); - } - if (!azurite.isRunning()) { - phase1.push(azurite.start()); - } - if (!oauth2.isRunning()) { - phase1.push(oauth2.start()); - } - if (phase1.length > 0) await Promise.all(phase1); - - apiServer ??= new TestApiServer(); - communityViteServer ??= new TestCommunityViteServer(); - staffViteServer ??= new TestStaffViteServer(); - - const api = apiServer; - const communityVite = communityViteServer; - const staffVite = staffViteServer; - const phase2: Promise[] = []; - - if (!api.isRunning()) { - phase2.push( - api.start().then(() => { - apiUrl = api.getUrl(); - }), - ); - } - if (!communityVite.isRunning()) { - phase2.push(communityVite.start()); - } - if (!staffVite.isRunning()) { - phase2.push(staffVite.start()); - } - if (phase2.length > 0) await Promise.all(phase2); - - browserBaseUrl = communityVite.getUrl(); - apiUrl ??= api.getUrl(); - - if (!browser) { - browser = await playwright.chromium.launch({ headless: true }); - } - - await ensureAuthenticatedBrowserContext({ - baseURL: browserBaseUrl, - ignoreHTTPSErrors: true, - performLogin: true, - }); -} - -async function ensureAuthenticatedBrowserContext(options: { baseURL?: string; ignoreHTTPSErrors: boolean; performLogin: boolean }): Promise { - if (browseTheWeb || !browser || !options.baseURL) { - return; - } - - if (!authenticatedBrowserContext) { - authenticatedBrowserContext = await browser.newContext({ - baseURL: options.baseURL, - ignoreHTTPSErrors: options.ignoreHTTPSErrors, - }); - } - - const seedPage = await authenticatedBrowserContext.newPage(); - - try { - if (options.performLogin) { - await performOAuth2Login(seedPage); - } - browseTheWeb = BrowseTheWeb.using(seedPage, authenticatedBrowserContext); - } catch (error) { - await authenticatedBrowserContext.close().catch(() => undefined); - authenticatedBrowserContext = undefined; - 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/shared/test-server-factories.ts b/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts new file mode 100644 index 000000000..3c51cb5c0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts @@ -0,0 +1,108 @@ +import { join } from 'node:path'; +import { ApiTestServer, AuthTestServer, AzuriteTestServer, UiPortalTestServer } from '@cellix/serenity-framework/servers'; +import { appPaths } from './environment/app-paths.ts'; +import { e2eEnv, getPortlessDevScript } from './environment/dev-script.ts'; +import { buildUrl, cleanupTestEnvironment, getHostnames, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer } from './environment/test-environment.ts'; +import { getAzuritePorts } from './environment/worktree-ports.ts'; + +const hostnames = getHostnames(); + +export { buildUrl, cleanupTestEnvironment, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer }; + +export function createTestApiServer(getMongoConnectionString: () => string): ApiTestServer { + return new ApiTestServer({ + cwd: appPaths.apiDir, + executable: 'pnpm', + extraEnv: () => + e2eEnv({ + COSMOSDB_CONNECTION_STRING: getMongoConnectionString(), + }), + getUrl: () => buildUrl(hostnames.api, '/api/graphql'), + probe: { + url: () => buildUrl(hostnames.api, '/api/graphql'), + requestInit: () => ({ + body: JSON.stringify({ query: '{ __typename }' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + isHealthy: async (response) => { + 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; + }, + }, + readyMarker: 'Functions:', + serverName: 'TestApiServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createTestAzuriteServer(): AzuriteTestServer { + return new AzuriteTestServer({ + cwd: appPaths.apiDir, + executable: 'node', + extraEnv: () => { + const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); + const { PATH: pathValue = '' } = process.env; + return { PATH: `${binDir}:${pathValue}` }; + }, + getUrl: () => `http://127.0.0.1:${getAzuritePorts().blob}`, + isAlreadyRunning: async () => false, + isReusableExit: (stderrOutput) => stderrOutput.includes('EADDRINUSE'), + probe: false, + readyMarker: '[azurite] started', + serverName: 'TestAzuriteServer', + spawnArgs: ['start-azurite.mjs'], + }); +} + +export function createTestOAuth2Server(): AuthTestServer { + return new AuthTestServer({ + cwd: appPaths.oauth2MockDir, + executable: 'pnpm', + getUrl: () => mockOidcIssuer, + probe: { + url: mockOidcEndpoint, + }, + readyMarker: 'Registered OIDC config', + serverName: 'TestOAuth2Server', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createCommunityUiPortalServer(): UiPortalTestServer { + return new UiPortalTestServer({ + cwd: appPaths.uiCommunityDir, + executable: 'pnpm', + extraEnv: () => ({ + BROWSER: 'none', + NODE_ENV: 'development', + }), + getUrl: () => buildUrl(hostnames.uiCommunity), + readyMarker: 'ready in', + serverName: 'TestCommunityViteServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} + +export function createStaffUiPortalServer(): UiPortalTestServer { + return new UiPortalTestServer({ + cwd: appPaths.uiStaffDir, + executable: 'pnpm', + extraEnv: () => ({ + BROWSER: 'none', + NODE_ENV: 'development', + }), + getUrl: () => buildUrl(hostnames.uiStaff), + readyMarker: 'ready in', + serverName: 'TestStaffViteServer', + spawnArgs: () => ['run', getPortlessDevScript()], + }); +} 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 fb2ff8a4f..1b7c8d7b3 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -3,6 +3,6 @@ * Cucumber imports this file, which then loads all context-specific step definitions. */ -import '../shared/support/hooks.ts'; +import '../shared/cucumber-lifecycle-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 e40e2eb21..b9da6e5e8 100644 --- a/packages/ocom-verification/e2e-tests/src/world.ts +++ b/packages/ocom-verification/e2e-tests/src/world.ts @@ -1,30 +1,38 @@ -import { setWorldConstructor, World } from '@cucumber/cucumber'; -import { engage } from '@serenity-js/core'; -import './shared/support/hooks.ts'; -import { CellixE2ECast } from './shared/support/cast.ts'; -import * as infra from './shared/support/shared-infrastructure.ts'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; +import './shared/cucumber-lifecycle-hooks.ts'; +import { OAuth2Login } from './shared/abilities/oauth2-login.ts'; +import * as infra from './shared/shared-infrastructure.ts'; export async function stopSharedServers(): Promise { await infra.stopAll(); } -export class CellixE2EWorld extends World { - async init(): Promise { - await infra.ensureE2EServers(); - - const { browseTheWeb } = infra.getState(); - if (!browseTheWeb) { +export const CellixE2EWorld = registerManagedSerenityWorld({ + infrastructure: { + ensureStarted: infra.ensureE2EServers, + getState: infra.getState, + resetScenarioState: infra.resetScenarioState, + stopAll: infra.stopAll, + }, + validateState: (state) => { + if (!state.browseTheWeb) { throw new Error('BrowseTheWeb ability not initialized'); } + }, + createCast: (state) => + new SerenityCast({ + useNotepad: true, + abilities: [ + () => { + if (!state.browseTheWeb) { + throw new Error('BrowseTheWeb ability not initialized'); + } + return state.browseTheWeb; + }, + () => OAuth2Login.throughProtectedRoute('/community/accounts'), + ], + }), +}); - engage(new CellixE2ECast(browseTheWeb)); - } - - async cleanup(): Promise { - // 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(); - } -} - -setWorldConstructor(CellixE2EWorld); +export type CellixE2EWorld = InstanceType; diff --git a/packages/ocom-verification/verification-shared/package.json b/packages/ocom-verification/verification-shared/package.json index 9d4803c01..e135dfb0f 100644 --- a/packages/ocom-verification/verification-shared/package.json +++ b/packages/ocom-verification/verification-shared/package.json @@ -1,41 +1,22 @@ { "name": "@ocom-verification/verification-shared", "version": "1.0.0", - "description": "Shared Serenity verification utilities, servers, scenarios, and helpers", + "description": "OCOM verification test data and shared Cucumber scenarios", "private": true, "type": "module", "exports": { - "./test-data": "./src/test-data/index.ts", - "./helpers": "./src/helpers/index.ts", - "./formatters": "./src/formatters/index.ts", - "./servers": "./src/servers/index.ts", - "./settings": "./src/settings/index.ts", - "./serenity": "./src/serenity/index.ts", + "./abilities": "./src/abilities/index.ts", "./pages": "./src/pages/index.ts", - "./pages/jsdom": "./src/pages/adapters/jsdom-adapter.ts", - "./pages/playwright": "./src/pages/adapters/playwright-adapter.ts" + "./test-data": "./src/test-data/index.ts" }, "dependencies": { - "@apollo/server": "catalog:", - "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", - "@ocom/service-mongoose": "workspace:*", - "@cucumber/cucumber": "catalog:", - "@cucumber/messages": "catalog:", + "@cellix/serenity-framework": "workspace:*", "@serenity-js/core": "catalog:", - "@ocom/graphql": "workspace:*", - "@ocom/application-services": "workspace:*", - "@testing-library/react": "^16.3.0", - "graphql": "catalog:", - "graphql-depth-limit": "^1.1.0", - "graphql-middleware": "^6.1.35", - "mongodb": "catalog:", - "mongoose": "catalog:" + "mongodb": "catalog:" }, "devDependencies": { "@cellix/config-typescript": "workspace:*", - "@types/graphql-depth-limit": "^1.1.6", "@types/node": "catalog:", - "playwright": "catalog:", "typescript": "catalog:" } } diff --git a/packages/ocom-verification/verification-shared/src/abilities/create-community.ts b/packages/ocom-verification/verification-shared/src/abilities/create-community.ts new file mode 100644 index 000000000..a53c51cb7 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/abilities/create-community.ts @@ -0,0 +1,53 @@ +import { Ability, type Actor } from '@serenity-js/core'; + +/** Community details accepted by OCOM verification flows. */ +export interface CreateCommunityDetails { + /** Community display name. */ + name: string; +} + +/** Result returned by a concrete community creation flow. */ +export interface CreateCommunityResult { + /** Created community id, when the flow exposes one. */ + id?: string; + + /** Created community name. */ + name: string; +} + +/** Handler that performs community creation for an actor. */ +export type CreateCommunityHandler = (actor: Actor, details: CreateCommunityDetails) => Promise; + +/** + * Serenity ability that lets an actor create OCOM communities. + * + * The ability centralizes the domain capability while allowing each verification + * package to provide the environment-specific implementation. + */ +export class CreateCommunity extends Ability { + /** + * @param handler Function that performs community creation. + */ + constructor(private readonly handler: CreateCommunityHandler) { + super(); + } + + /** + * Create the ability from an environment-specific community creation handler. + * + * @param handler Function that performs community creation. + */ + static using(handler: CreateCommunityHandler): CreateCommunity { + return new CreateCommunity(handler); + } + + /** + * Create a community through the configured verification environment. + * + * @param actor Actor creating the community. + * @param details Community details. + */ + async performAs(actor: Actor, details: CreateCommunityDetails): Promise { + return await this.handler(actor, details); + } +} diff --git a/packages/ocom-verification/verification-shared/src/abilities/index.ts b/packages/ocom-verification/verification-shared/src/abilities/index.ts new file mode 100644 index 000000000..65388a4ab --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/abilities/index.ts @@ -0,0 +1,2 @@ +export type { CreateCommunityDetails, CreateCommunityHandler, CreateCommunityResult } from './create-community.ts'; +export { CreateCommunity } from './create-community.ts'; diff --git a/packages/ocom-verification/verification-shared/src/formatters/index.ts b/packages/ocom-verification/verification-shared/src/formatters/index.ts deleted file mode 100644 index a75c8ca6b..000000000 --- a/packages/ocom-verification/verification-shared/src/formatters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, default as AgentFormatter } from './agent-formatter.ts'; diff --git a/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts deleted file mode 100644 index 88c35b0dc..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/actor-helpers.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ActorDetails { - name: string; - externalId: string; - email: string; - givenName: string; - familyName: string; -} - -// Resolve Gherkin pronoun references to actor names -export function resolveActorName(actorName: string, defaultName = 'Alice'): string { - return /^(she|he|they)$/i.test(actorName) ? defaultName : actorName; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts deleted file mode 100644 index da0a6b8f6..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/date-helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const ONE_DAY_MS = 86_400_000; - -export function parseDateInput(input: string): Date { - if (input.startsWith('+')) { - const days = Number.parseInt(input.substring(1), 10); - const date = new Date(); - date.setDate(date.getDate() + days); - date.setHours(0, 0, 0, 0); - return date; - } - - const date = new Date(input); - if (Number.isNaN(date.getTime())) { - throw new TypeError(`Invalid date input: "${input}"`); - } - date.setHours(0, 0, 0, 0); - return date; -} - -export function formatDateForComparison(date: Date): string { - return date.toISOString().split('T')[0] ?? ''; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts deleted file mode 100644 index db0b9c910..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/gherkin-helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { DataTable } from '@cucumber/cucumber'; - -/** - * Returns a DataTable's rowsHash typed as the caller-provided shape so consumers - * can access keys directly (e.g. `row.name`) without tripping - * TypeScript's `noPropertyAccessFromIndexSignature` rule. - */ -export function typedRowsHash(dataTable: DataTable): T { - return dataTable.rowsHash() as T; -} diff --git a/packages/ocom-verification/verification-shared/src/helpers/index.ts b/packages/ocom-verification/verification-shared/src/helpers/index.ts deleted file mode 100644 index a9b4a6381..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { ActorDetails } from './actor-helpers.ts'; -export { resolveActorName } from './actor-helpers.ts'; -export { - formatDateForComparison, - ONE_DAY_MS, - parseDateInput, -} from './date-helpers.ts'; -export { typedRowsHash } from './gherkin-helpers.ts'; -export type { TestUserData } from './user-helpers.ts'; -export { makeTestUserData } from './user-helpers.ts'; diff --git a/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts b/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts deleted file mode 100644 index f8a92a399..000000000 --- a/packages/ocom-verification/verification-shared/src/helpers/user-helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface TestUserData { - id: string; - email: string; - firstName: string; - lastName: string; -} - -export function makeTestUserData(actorName: string, overrides?: Partial): TestUserData { - const defaultId = `test-user-${actorName.toLowerCase()}`; - const defaultEmail = `${actorName.toLowerCase()}@test.com`; - const defaultFirstName = actorName; - const defaultLastName = 'Tester'; - - return { - id: overrides?.id ?? defaultId, - email: overrides?.email ?? defaultEmail, - firstName: overrides?.firstName ?? defaultFirstName, - lastName: overrides?.lastName ?? defaultLastName, - }; -} diff --git a/packages/ocom-verification/verification-shared/src/pages/community.page.ts b/packages/ocom-verification/verification-shared/src/pages/community.page.ts index 91317f1aa..a9b333811 100644 --- a/packages/ocom-verification/verification-shared/src/pages/community.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/community.page.ts @@ -1,29 +1,14 @@ -import type { ElementHandle, PageAdapter } from './page-adapter.ts'; +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; -/** - * Community page object — works with both jsdom (acceptance UI tests) - * and Playwright (e2e tests) via the PageAdapter abstraction. - */ -export class CommunityPage { - constructor(private readonly adapter: PageAdapter) {} - - // --- Create Community form --- +export class CommunityPage extends AdapterBackedPageObject { get nameInput(): ElementHandle { return this.adapter.getByPlaceholder('Name'); } - get createCommunityButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /Create.*Community/i }); - } - get submitButton(): ElementHandle { return this.adapter.getByRole('button', { name: /Create/i }); } - get cancelButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /Cancel/i }); - } - get firstValidationError(): ElementHandle { return this.adapter.locator('.ant-form-item-explain-error'); } @@ -32,54 +17,11 @@ export class CommunityPage { return this.adapter.locator('.ant-message-error, [role="alert"]'); } - // --- Loading indicator --- - get loadingButton(): ElementHandle { - return this.adapter.locator('.ant-btn-loading'); - } - - // --- Success modal --- - get modal(): ElementHandle { - return this.adapter.locator('.ant-modal'); - } - - get viewCommunityButton(): ElementHandle { - return this.adapter.getByRole('button', { name: /View Community/i }); - } - - // --- Community list table --- - communityNameCell(name: string): ElementHandle { - return this.adapter.getByText(name, { selector: 'table' }); - } - - async statusTagInRow(name: string): Promise { - const row = await this.communityRowByName(name); - return row ? row.querySelector('.ant-tag') : null; - } - - // --- Helper methods --- async fillName(value: string): Promise { await this.nameInput.fill(value); } - async fillForm(data: { name?: string }): Promise { - if (data.name) await this.fillName(data.name); - } - async clickCreate(): Promise { await this.submitButton.click(); } - - private async communityRowByName(name: string): Promise { - const table = this.adapter.getByRole('table'); - const rows = await table.querySelectorAll('tr'); - - for (const row of rows) { - const text = await row.textContent(); - if (text?.includes(name)) { - return row; - } - } - - return null; - } } diff --git a/packages/ocom-verification/verification-shared/src/pages/home.page.ts b/packages/ocom-verification/verification-shared/src/pages/home.page.ts index 435596ca3..1642abb1f 100644 --- a/packages/ocom-verification/verification-shared/src/pages/home.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/home.page.ts @@ -1,13 +1,6 @@ -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) {} +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; +export class HomePage extends AdapterBackedPageObject { get signInButton(): ElementHandle { return this.adapter.getByRole('button', { name: /Log In|Sign In/i }); } diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 8c8ff6683..63aa501c8 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,18 +1,2 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; -export { LoginPage } from './login.page.ts'; -export type { - ElementHandle, - PageAdapter, - PageAdapterMode, - PageNavigationWaitUntil, - PageUrlMatcher, -} 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/login.page.ts b/packages/ocom-verification/verification-shared/src/pages/login.page.ts deleted file mode 100644 index 9e8458800..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/login.page.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { PageAdapter } from './page-adapter.ts'; - -/** - * Shared login page object backed by the universal page adapter. - */ -export class LoginPage { - constructor(private readonly page: PageAdapter) {} - - get emailInput() { - return this.page.getByLabel('Email'); - } - - get passwordInput() { - return this.page.getByLabel('Password'); - } - - get loginButton() { - return this.page.locator('form button[type="submit"]'); - } - - async goto(): Promise { - await this.page.goto('/login', { waitUntil: 'networkidle' }); - } - - async login(email: string, password: string): Promise { - await this.emailInput.fill(email); - await this.passwordInput.fill(password); - await this.loginButton.click(); - } - - async waitForRedirectComplete(): Promise { - await this.page.waitForURL((url) => !url.pathname.includes('auth-redirect') && !url.pathname.includes('/login') && !url.hostname.includes('mock-auth'), { timeout: 30_000 }); - } -} diff --git a/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts b/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts deleted file mode 100644 index 71b2cc1ef..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-adapter.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Universal element handle — wraps a single DOM element or Playwright locator. - * Provides a common interface for both jsdom (acceptance-test UI) and Playwright (e2e) contexts. - */ -export interface ElementHandle { - fill(value: string): Promise; - click(): Promise; - check(): Promise; - textContent(): Promise; - getAttribute(name: string): Promise; - isVisible(): Promise; - waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise; - querySelector(selector: string): Promise; - querySelectorAll(selector: string): Promise; -} - -export type PageNavigationWaitUntil = 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; - -export type PageUrlMatcher = string | RegExp | ((url: URL) => boolean); - -/** - * Universal page adapter — abstracts element lookup across jsdom and Playwright. - * Page objects depend on this interface rather than a specific test runner. - */ -export interface PageAdapter { - getByPlaceholder(text: string): ElementHandle; - getByLabel(text: string): ElementHandle; - getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; - locator(selector: string): ElementHandle; - locatorAll(selector: string): Promise; - getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle; - goto(url: string, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise; - waitForURL(url: PageUrlMatcher, options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }): Promise; - url(): string; - waitForTimeout(timeout: number): Promise; -} - -export type PageAdapterMode = 'jsdom' | 'playwright'; 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 deleted file mode 100644 index 584459540..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/community.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CommunityPage } from '../community.page.ts'; - -export type UiCommunityPage = 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 deleted file mode 100644 index e279f8c47..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/home.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 9a095a538..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { - E2ECommunityPage, - UiCommunityPage, -} from './community.page-interface.ts'; -export type { - E2EHomePage, - UiHomePage, -} from './home.page-interface.ts'; -export type { - E2ELoginPage, - UiLoginPage, -} from './login.page-interface.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts b/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts deleted file mode 100644 index 9792668a5..000000000 --- a/packages/ocom-verification/verification-shared/src/pages/page-interfaces/login.page-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { LoginPage } from '../login.page.ts'; - -export type UiLoginPage = Pick; - -export type E2ELoginPage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/serenity/index.ts b/packages/ocom-verification/verification-shared/src/serenity/index.ts deleted file mode 100644 index 54ba8c2e5..000000000 --- a/packages/ocom-verification/verification-shared/src/serenity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './task-step.ts'; diff --git a/packages/ocom-verification/verification-shared/src/serenity/task-step.ts b/packages/ocom-verification/verification-shared/src/serenity/task-step.ts deleted file mode 100644 index b11ff0a82..000000000 --- a/packages/ocom-verification/verification-shared/src/serenity/task-step.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Task } from '@serenity-js/core'; - -export class TaskStep extends Task { - constructor( - description: string, - private readonly action: (actor: unknown) => Promise, - ) { - super(description); - } - - performAs(actor: unknown): Promise { - return this.action(actor); - } -} diff --git a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts b/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts deleted file mode 100644 index acae9ca37..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/graphql-test-server.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ApolloServer } from '@apollo/server'; -import { startStandaloneServer } from '@apollo/server/standalone'; -import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; -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; -} - -const MAX_QUERY_DEPTH = 10; - -/** - * 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) {} - - /** - * 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'); - } - - const securedSchema = applyMiddleware(combinedSchema); - - this.server = new ApolloServer({ - schema: securedSchema, - allowBatchedHttpRequests: true, - validationRules: [depthLimit(MAX_QUERY_DEPTH)], - introspection: false, - }); - - const timeoutMs = getTimeout('serverStartup'); - const startTime = Date.now(); - - const { url } = await startStandaloneServer(this.server, { - listen: { port }, - context: async ({ req }) => { - const authHeader = req.headers.authorization ?? undefined; - - const applicationServices = this.applicationServicesFactory ? await this.applicationServicesFactory.forRequest(authHeader) : undefined; - - if (!applicationServices) { - throw new Error('ApplicationServicesFactory required for test server'); - } - - return { - applicationServices, - }; - }, - }); - - const elapsed = Date.now() - startTime; - if (elapsed > timeoutMs * 0.8) { - console.warn(`GraphQLTestServer startup took ${elapsed}ms (timeout: ${timeoutMs}ms)`); - } - - this.url = url; - } - - /** - * Stop the server gracefully. - */ - async stop(): Promise { - if (!this.server) { - return; - } - - await this.server.stop(); - this.server = null; - 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'); - } - 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 deleted file mode 100644 index 4f3a5ec63..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { seedDatabase } from '../test-data/index.ts'; -export { GraphQLTestServer } from './graphql-test-server.ts'; -export type { - MongoDBSeedDataFunction, - MongoDBTestServerStartOptions, -} from './test-mongodb-server.ts'; -export { MongoDBTestServer } from './test-mongodb-server.ts'; -export type { TestServer } from './test-server.interface.ts'; diff --git a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts b/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts deleted file mode 100644 index 7ae4a488f..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/test-mongodb-server.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type MongoMemoryReplicaSetConfig, type MongoMemoryReplicaSetDisposer, startMongoMemoryReplicaSet } from '@cellix/server-mongodb-memory-mock-seedwork'; -import { ServiceMongoose } from '@ocom/service-mongoose'; -import { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from '@ocom-verification/verification-shared/test-data'; -import { MongoClient } from 'mongodb'; - -const DEFAULT_DB_NAME = 'owner-community'; -const DEFAULT_MONGO_PORT = 50_000; -const DEFAULT_REPL_SET_NAME = 'globaldb'; - -export type { MongoDBSeedDataFunction }; - -export interface MongoDBTestServerStartOptions { - dbName?: string; - port?: number; - replSetName?: string; - binaryVersion?: string; - attachMongoose?: boolean; - seedData?: MongoDBSeedDataFunction; -} - -/** - * In-memory MongoDB replica set for verification tests. - */ -export class MongoDBTestServer { - private disposer: MongoMemoryReplicaSetDisposer | null = null; - private serviceMongoose: ServiceMongoose | null = null; - private connectionString = ''; - private dbName = DEFAULT_DB_NAME; - private seedData: MongoDBSeedDataFunction = seedDatabase; - - async start(options?: MongoDBTestServerStartOptions): Promise { - const config: MongoMemoryReplicaSetConfig = { - port: options?.port ?? DEFAULT_MONGO_PORT, - dbName: options?.dbName ?? DEFAULT_DB_NAME, - replSetName: options?.replSetName ?? DEFAULT_REPL_SET_NAME, - ...(options?.binaryVersion && { binaryVersion: options.binaryVersion }), - }; - - this.dbName = config.dbName; - this.seedData = options?.seedData ?? seedDatabase; - - const { connectionString, disposer } = await startMongoMemoryReplicaSet(config); - this.disposer = disposer; - this.connectionString = connectionString; - await this.seed(); - - if (options?.attachMongoose) { - await this.attachMongoose(); - } - } - - getServiceMongoose(): ServiceMongoose { - if (!this.serviceMongoose) { - throw new Error('MongoDBTestServer Mongoose service not attached'); - } - return this.serviceMongoose; - } - - getConnectionString(): string { - if (!this.connectionString) { - throw new Error('MongoDBTestServer not started'); - } - return this.connectionString; - } - - async resetForScenario(seedData?: MongoDBSeedDataFunction): Promise { - if (!this.connectionString) { - throw new Error('MongoDBTestServer not started'); - } - - await clearDatabase({ connectionString: this.connectionString, dbName: this.dbName }); - await this.seed(seedData); - } - - async stop(): Promise { - if (this.serviceMongoose) { - await this.serviceMongoose.shutDown(); - this.serviceMongoose = null; - } - if (this.disposer) { - const disposer = this.disposer; - this.disposer = null; - await disposer.stop(); - } - this.connectionString = ''; - } - - isRunning(): boolean { - return this.disposer !== null; - } - - private async attachMongoose(): Promise { - this.serviceMongoose = new ServiceMongoose(this.connectionString, { - dbName: this.dbName, - autoIndex: true, - autoCreate: true, - }); - await this.serviceMongoose.startUp(); - this.clearMongooseModels(); - } - - private clearMongooseModels(): void { - const connection = this.serviceMongoose?.service.connection; - if (!connection) return; - - for (const modelName of Object.keys(connection.models)) { - try { - connection.deleteModel(modelName); - } catch { - /* already deleted */ - } - } - } - - private async seed(seedData = this.seedData): Promise { - await seedData({ connectionString: this.connectionString, dbName: this.dbName }); - } -} - -async function clearDatabase(context: MongoDBSeedContext): Promise { - const client = new MongoClient(context.connectionString); - try { - await client.connect(); - const db = client.db(context.dbName); - const collections = await db.listCollections({}, { nameOnly: true }).toArray(); - await Promise.all(collections.map((collection) => db.collection(collection.name).deleteMany({}))); - } finally { - await client.close(); - } -} diff --git a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts b/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts deleted file mode 100644 index 16e024f23..000000000 --- a/packages/ocom-verification/verification-shared/src/servers/test-server.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Common interface for all test servers (in-process and subprocess). - * Implemented by GraphQLTestServer (in-process), PortlessServer (subprocess - * via the portless proxy), and TestAzuriteServer. - */ -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; -} diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts deleted file mode 100644 index 5969fa475..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { buildPortlessUrl, getHostnames, PORTLESS_PORT } from './portless-settings.ts'; -export { getTimeout, type TimeoutKey, timeouts } from './timeout-settings.ts'; diff --git a/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts b/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts deleted file mode 100644 index 9415b0a64..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/portless-settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -interface PortlessHostnames { - uiCommunity: string; - uiStaff: string; - api: string; - mockAuth: string; - docs: string; -} - -const PORTLESS_PORT = 1355; - -function buildPortlessUrl(hostname: string, path = ''): string { - return `https://${hostname}:${PORTLESS_PORT}${path}`; -} - -function getHostnames(): PortlessHostnames { - const hostnames = { - uiCommunity: requireHostname('VITE_APP_UI_COMMUNITY_BASE_URL'), - uiStaff: requireHostname('VITE_APP_UI_STAFF_AAD_REDIRECT_URI'), - api: requireHostname('VITE_COMMON_API_ENDPOINT'), - mockAuth: requireHostname('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY'), - }; - const { WORKTREE_NAME: worktreeName = '' } = process.env; - - return applyWorktreeSuffixes(hostnames, worktreeName); -} - -function hostnameFrom(url: string): string | null { - try { - return new URL(url).hostname; - } catch { - return null; - } -} - -function requireHostname(key: string): string { - const hostname = hostnameFrom(process.env[key] ?? ''); - if (!hostname) { - throw new Error(`portless-settings: required env var ${key} is missing or invalid`); - } - return hostname; -} - -function applyWorktreeSuffixes(hostnames: Omit, worktreeName: string): PortlessHostnames { - return { - uiCommunity: applyWorktreeSuffix(hostnames.uiCommunity, worktreeName), - uiStaff: applyWorktreeSuffix(hostnames.uiStaff, worktreeName), - api: applyWorktreeSuffix(hostnames.api, worktreeName), - mockAuth: applyWorktreeSuffix(hostnames.mockAuth, worktreeName), - docs: applyWorktreeSuffix(`docs.${hostnames.uiCommunity}`, worktreeName), - }; -} - -function applyWorktreeSuffix(hostname: string, worktreeName: string): string { - if (!worktreeName) return hostname; - return hostname.replace('.localhost', `.${worktreeName}.localhost`); -} - -export { buildPortlessUrl, getHostnames, PORTLESS_PORT }; diff --git a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts deleted file mode 100644 index 89e55ebaa..000000000 --- a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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; - -function timeoutEnvName(key: TimeoutKey): string { - return `TIMEOUT_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`; -} - -/** - * Get timeout value with optional override from environment. - * Usage: TIMEOUT_SERVER_STARTUP=300000 npm test - */ -export function getTimeout(key: TimeoutKey): number { - const envName = timeoutEnvName(key); - const envOverride = process.env[envName]; - - if (envOverride) { - const parsed = Number(envOverride); - if (Number.isInteger(parsed) && parsed > 0) { - return parsed; - } - - console.warn(`Ignoring invalid ${envName} value "${envOverride}"; expected a positive integer.`); - } - - return timeouts[key]; -} diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts index 1141198e8..205524e72 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/end-users.ts @@ -1,5 +1,4 @@ -import type { ActorDetails } from '../../helpers/actor-helpers.ts'; -import { actors } from '../test-actors.ts'; +import { actors, type TestActor } from '../test-actors.ts'; export interface EndUserSeedDocument { _id: string; @@ -31,7 +30,7 @@ export const END_USER_IDS = { export const endUsers: EndUserSeedDocument[] = [createEndUserSeedDocument(END_USER_IDS.communityOwner, actors.CommunityOwner), createEndUserSeedDocument(END_USER_IDS.communityMember, actors.CommunityMember)]; -function createEndUserSeedDocument(id: string, actor: ActorDetails): EndUserSeedDocument { +function createEndUserSeedDocument(id: string, actor: TestActor): EndUserSeedDocument { return { _id: id, userType: 'end-users', diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 5c1a7f51d..5a027194a 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -1,6 +1,10 @@ -import type { ActorDetails } from '../helpers/actor-helpers.ts'; - -export type TestActor = ActorDetails; +export interface TestActor { + name: string; + externalId: string; + email: string; + givenName: string; + familyName: string; +} const communityOwner: TestActor = { name: 'CommunityOwner', diff --git a/packages/ocom-verification/verification-shared/src/test-data/utils.ts b/packages/ocom-verification/verification-shared/src/test-data/utils.ts index 6f2bf3a14..79a820f91 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/utils.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/utils.ts @@ -1,8 +1,8 @@ -import { Types } from 'mongoose'; +import { ObjectId } from 'mongodb'; /** * Generate a random MongoDB ObjectId string — useful for seeding test data. */ export function generateObjectId(): string { - return new Types.ObjectId().toHexString(); + return new ObjectId().toHexString(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3be5c052c..faafb27b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ catalogs: specifier: 7.0.0-dev.20260428.1 version: 7.0.0-dev.20260428.1 '@vitest/coverage-istanbul': - specifier: 4.1.2 - version: 4.1.2 + specifier: 4.1.6 + version: 4.1.6 antd: specifier: 6.3.5 version: 6.3.5 @@ -97,10 +97,12 @@ catalogs: specifier: ^0.28.0 version: 0.28.0 vitest: - specifier: 4.1.2 - version: 4.1.2 + specifier: 4.1.6 + version: 4.1.6 overrides: + '@vitest/browser': 4.1.6 + '@vitest/browser-playwright': 4.1.6 axios: 1.16.0 follow-redirects: ^1.16.0 vite: 8.0.5 @@ -157,7 +159,7 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: ^6.3.0 - version: 6.3.0(vitest@4.1.2) + version: 6.3.0(vitest@4.1.6) '@ant-design/cli': specifier: ^6.3.5 version: 6.3.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) @@ -202,7 +204,7 @@ importers: version: 7.0.0-dev.20260428.1 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) chrome-devtools-mcp: specifier: ^0.21.0 version: 0.21.0 @@ -235,7 +237,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) apps/api: dependencies: @@ -299,7 +301,7 @@ importers: version: link:../../packages/cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) azurite: specifier: ^3.35.0 version: 3.35.0(@azure/core-client@1.10.1)(@types/node@24.10.1) @@ -314,7 +316,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) apps/docs: dependencies: @@ -375,7 +377,7 @@ importers: version: 6.0.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/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -384,7 +386,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) apps/server-mongodb-memory-mock: dependencies: @@ -425,7 +427,7 @@ importers: version: link:../../packages/cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -437,7 +439,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) apps/ui-community: dependencies: @@ -507,7 +509,7 @@ importers: 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))) '@storybook/addon-vitest': specifier: ^9.1.3 - version: 9.1.16(@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.16(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -528,7 +530,7 @@ importers: version: 6.0.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/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) esbuild: specifier: 'catalog:' version: 0.27.4 @@ -558,7 +560,7 @@ importers: version: 0.28.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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) apps/ui-staff: dependencies: @@ -634,7 +636,7 @@ importers: version: 6.0.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/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) esbuild: specifier: 'catalog:' version: 0.27.4 @@ -658,7 +660,7 @@ importers: version: 0.28.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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/api-services-spec: devDependencies: @@ -694,7 +696,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) packages/cellix/config-rolldown: devDependencies: @@ -706,7 +708,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -718,7 +720,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/config-typescript: {} @@ -729,16 +731,16 @@ importers: version: link:../config-typescript '@storybook/addon-vitest': specifier: ^9.1.20 - 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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) + specifier: 4.1.6 + version: 4.1.6(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.6) 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/domain-seedwork: devDependencies: @@ -750,7 +752,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -759,7 +761,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/event-bus-seedwork-node: dependencies: @@ -778,7 +780,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -809,7 +811,7 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: ^6.3.0 - version: 6.3.0(vitest@4.1.2) + version: 6.3.0(vitest@4.1.6) '@cellix/config-typescript': specifier: workspace:* version: link:../config-typescript @@ -818,7 +820,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -827,7 +829,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/graphql-core: dependencies: @@ -846,7 +848,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -855,7 +857,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/mongoose-seedwork: dependencies: @@ -874,7 +876,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) mongodb: specifier: 'catalog:' version: 6.18.0 @@ -892,7 +894,74 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) + + packages/cellix/serenity-framework: + dependencies: + '@apollo/server': + specifier: 'catalog:' + version: 5.5.0(graphql@16.12.0) + '@cellix/server-mongodb-memory-mock-seedwork': + specifier: workspace:* + version: link:../server-mongodb-memory-mock-seedwork + '@cucumber/cucumber': + specifier: 'catalog:' + version: 12.8.1 + '@cucumber/messages': + specifier: 'catalog:' + version: 32.3.1 + '@serenity-js/core': + specifier: 'catalog:' + version: 3.42.2 + graphql: + specifier: 'catalog:' + version: 16.12.0 + graphql-depth-limit: + specifier: ^1.1.0 + version: 1.1.0(graphql@16.12.0) + jsdom: + specifier: 'catalog:' + version: 26.1.0 + mongodb: + specifier: 'catalog:' + version: 6.18.0 + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../config-vitest + '@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) + '@types/graphql-depth-limit': + specifier: ^1.1.0 + version: 1.1.6 + '@types/node': + specifier: 'catalog:' + version: 22.19.15 + '@types/react': + specifier: ^19.1.8 + version: 19.2.7 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.6(vitest@4.1.6) + playwright: + specifier: 1.59.0 + version: 1.59.0 + react: + specifier: ^19.1.0 + version: 19.2.0 + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) packages/cellix/server-mongodb-memory-mock-seedwork: dependencies: @@ -936,7 +1005,7 @@ importers: version: 5.0.5 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -945,7 +1014,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/cellix/ui-core: dependencies: @@ -979,7 +1048,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -996,14 +1065,14 @@ importers: specifier: ^19.1.6 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) + specifier: 4.1.6 + version: 4.1.6(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.6) '@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) + specifier: 4.1.6 + version: 4.1.6(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.6) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) jsdom: specifier: 'catalog:' version: 26.1.0 @@ -1024,10 +1093,13 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom-verification/acceptance-api: dependencies: + '@cellix/serenity-framework': + specifier: workspace:* + version: link:../../cellix/serenity-framework '@cucumber/cucumber': specifier: 'catalog:' version: 12.8.1 @@ -1046,6 +1118,15 @@ importers: '@serenity-js/serenity-bdd': specifier: 'catalog:' version: 3.42.2 + graphql: + specifier: 'catalog:' + version: 16.12.0 + graphql-depth-limit: + specifier: ^1.1.0 + version: 1.1.0(graphql@16.12.0) + graphql-middleware: + specifier: ^6.1.35 + version: 6.1.35(graphql@16.12.0) std-env: specifier: ^4.0.0 version: 4.0.0 @@ -1062,6 +1143,9 @@ importers: '@ocom/context-spec': specifier: workspace:* version: link:../../ocom/context-spec + '@ocom/graphql': + specifier: workspace:* + version: link:../../ocom/graphql '@ocom/persistence': specifier: workspace:* version: link:../../ocom/persistence @@ -1074,6 +1158,9 @@ importers: '@ocom/service-token-validation': specifier: workspace:* version: link:../../ocom/service-token-validation + '@types/graphql-depth-limit': + specifier: ^1.1.6 + version: 1.1.6 '@types/node': specifier: 'catalog:' version: 22.19.15 @@ -1095,6 +1182,9 @@ importers: '@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) + '@cellix/serenity-framework': + specifier: workspace:* + version: link:../../cellix/serenity-framework '@cucumber/cucumber': specifier: 'catalog:' version: 12.8.1 @@ -1153,9 +1243,6 @@ importers: c8: specifier: ^10.1.3 version: 10.1.3 - jsdom: - specifier: ^26.1.0 - version: 26.1.0 tsx: specifier: ^4.20.3 version: 4.21.0 @@ -1182,10 +1269,13 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) packages/ocom-verification/e2e-tests: dependencies: + '@cellix/serenity-framework': + specifier: workspace:* + version: link:../../cellix/serenity-framework '@cucumber/cucumber': specifier: 'catalog:' version: 12.8.1 @@ -1232,61 +1322,22 @@ importers: packages/ocom-verification/verification-shared: dependencies: - '@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 - '@cucumber/messages': - specifier: 'catalog:' - version: 32.3.1 - '@ocom/application-services': - specifier: workspace:* - version: link:../../ocom/application-services - '@ocom/graphql': - specifier: workspace:* - version: link:../../ocom/graphql - '@ocom/service-mongoose': + '@cellix/serenity-framework': specifier: workspace:* - version: link:../../ocom/service-mongoose + version: link:../../cellix/serenity-framework '@serenity-js/core': specifier: 'catalog:' version: 3.42.2 - '@testing-library/react': - specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - graphql: - specifier: 'catalog:' - version: 16.12.0 - graphql-depth-limit: - specifier: ^1.1.0 - version: 1.1.0(graphql@16.12.0) - graphql-middleware: - specifier: ^6.1.35 - version: 6.1.35(graphql@16.12.0) mongodb: specifier: 'catalog:' version: 6.18.0 - mongoose: - specifier: 'catalog:' - version: 8.17.0 devDependencies: '@cellix/config-typescript': specifier: workspace:* version: link:../../cellix/config-typescript - '@types/graphql-depth-limit': - specifier: ^1.1.6 - version: 1.1.6 '@types/node': specifier: 'catalog:' version: 22.19.15 - playwright: - specifier: 1.59.0 - version: 1.59.0 typescript: specifier: 'catalog:' version: 6.0.3 @@ -1317,7 +1368,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1326,7 +1377,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/context-spec: dependencies: @@ -1379,7 +1430,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/domain: dependencies: @@ -1431,7 +1482,7 @@ importers: version: 3.42.2 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1440,7 +1491,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/event-handler: dependencies: @@ -1493,7 +1544,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1502,7 +1553,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/graphql-handler: dependencies: @@ -1536,7 +1587,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1545,7 +1596,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/persistence: dependencies: @@ -1585,7 +1636,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1594,7 +1645,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/rest: dependencies: @@ -1647,7 +1698,7 @@ importers: version: 1.1.6 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1656,7 +1707,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/service-blob-storage: dependencies: @@ -1697,7 +1748,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1755,7 +1806,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1764,7 +1815,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/service-token-validation: dependencies: @@ -1783,7 +1834,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1792,7 +1843,7 @@ importers: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-community-route-accounts: dependencies: @@ -1859,7 +1910,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -1886,7 +1937,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-community-route-admin: dependencies: @@ -1953,7 +2004,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -1980,7 +2031,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-community-route-root: dependencies: @@ -2023,7 +2074,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -2050,7 +2101,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-community-shared: dependencies: @@ -2102,7 +2153,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -2129,7 +2180,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-shared: dependencies: @@ -2187,7 +2238,7 @@ importers: 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))) '@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.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6) '@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) @@ -2201,11 +2252,11 @@ importers: specifier: ^19.1.6 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) + specifier: 4.1.6 + version: 4.1.6(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.6) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.2(vitest@4.1.2) + version: 4.1.6(vitest@4.1.6) jsdom: specifier: 'catalog:' version: 26.1.0 @@ -2226,7 +2277,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-route-community-management: dependencies: @@ -2269,7 +2320,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-route-finance: dependencies: @@ -2312,7 +2363,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-route-root: dependencies: @@ -2355,7 +2406,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-route-tech-admin: dependencies: @@ -2398,7 +2449,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-route-user-management: dependencies: @@ -2441,7 +2492,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages/ocom/ui-staff-shared: dependencies: @@ -2490,7 +2541,7 @@ importers: version: 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: 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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) packages: @@ -6248,8 +6299,8 @@ packages: '@storybook/addon-vitest@9.1.16': resolution: {integrity: sha512-X0rOOUMb5UHbfekcjnTeiDTarZdsg5irXXPxxL//8QQCFyCLF6Bdm1YNlCdF560PtwaaQPXzlxByD0FfGbtdWA==} peerDependencies: - '@vitest/browser': ^3.0.0 || ^4.0.0 - '@vitest/browser-playwright': ^4.0.0 + '@vitest/browser': 4.1.6 + '@vitest/browser-playwright': 4.1.6 '@vitest/runner': ^3.0.0 || ^4.0.0 storybook: ^9.1.16 vitest: ^3.0.0 || ^4.0.0 @@ -6266,8 +6317,8 @@ packages: '@storybook/addon-vitest@9.1.20': resolution: {integrity: sha512-6zN/qe9Z/7pklbUQJrnjwdTTqRG5PWy4Tyx1J90nwQq6yekj436S8/5NrvXaSns0zYNoZLsynh8nWZPweeu05w==} peerDependencies: - '@vitest/browser': ^3.0.0 || ^4.0.0 - '@vitest/browser-playwright': ^4.0.0 + '@vitest/browser': 4.1.6 + '@vitest/browser-playwright': 4.1.6 '@vitest/runner': ^3.0.0 || ^4.0.0 storybook: ^9.1.20 vitest: ^3.0.0 || ^4.0.0 @@ -6785,27 +6836,27 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-playwright@4.1.2': - resolution: {integrity: sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg==} + '@vitest/browser-playwright@4.1.6': + resolution: {integrity: sha512-4csoeyl/qwHyxU2zNL0++WaoDr8YJDXOQPwWPNJoTZ+QzcdO3INYKgF5Zfz730Io7zbkuv914aZmfQ+QE+1Hvw==} peerDependencies: playwright: 1.59.0 - vitest: 4.1.2 + vitest: 4.1.6 - '@vitest/browser@4.1.2': - resolution: {integrity: sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==} + '@vitest/browser@4.1.6': + resolution: {integrity: sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==} peerDependencies: - vitest: 4.1.2 + vitest: 4.1.6 - '@vitest/coverage-istanbul@4.1.2': - resolution: {integrity: sha512-WSz7+4a7PcMtMNvIP7AXUMffsq4JrWeJaguC8lg6fSQyGxSfaT4Rf81idqwxTT6qX5kjjZw2t9rAnCRRQobSqw==} + '@vitest/coverage-istanbul@4.1.6': + resolution: {integrity: sha512-lOt/VDh+sihAx3OUxCE5CC0qZfAhIzE3Dxw75NJ3P0C6ruUgT9b/jZKECE1ctpbxSVic9OkLdXz5UEX39ks4Sw==} peerDependencies: - vitest: 4.1.2 + vitest: 4.1.6 '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.2': - resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -6818,8 +6869,8 @@ packages: vite: optional: true - '@vitest/mocker@4.1.2': - resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 vite: 8.0.5 @@ -6832,26 +6883,26 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} - '@vitest/runner@4.1.2': - resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} - '@vitest/snapshot@4.1.2': - resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.2': - resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -13121,18 +13172,20 @@ packages: yaml: optional: true - vitest@4.1.2: - resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.2 - '@vitest/browser-preview': 4.1.2 - '@vitest/browser-webdriverio': 4.1.2 - '@vitest/ui': 4.1.2 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' vite: 8.0.5 @@ -13149,6 +13202,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -13601,13 +13658,13 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.2)': + '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.6)': dependencies: callsites: 4.2.0 minimist: 1.2.8 parsecurrency: 1.1.1 ts-morph: 27.0.2 - vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) '@ant-design/cli@6.3.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': dependencies: @@ -15279,7 +15336,7 @@ snapshots: '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.2.0) - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) '@cucumber/messages': 32.2.0 '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0) '@cucumber/tag-expressions': 9.1.0 @@ -15315,7 +15372,7 @@ snapshots: '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) '@cucumber/messages': 32.2.0 commander: 14.0.0 source-map-support: 0.5.21 @@ -15364,9 +15421,9 @@ snapshots: luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0)': + '@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 mime: 3.0.0 '@cucumber/messages@26.0.1': @@ -18457,7 +18514,7 @@ snapshots: 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)) - '@storybook/addon-vitest@9.1.16(@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)': + '@storybook/addon-vitest@9.1.16(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 1.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -18465,15 +18522,15 @@ snapshots: 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)) 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.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/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) - '@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.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.6(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.6) + '@vitest/browser-playwright': 4.1.6(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.6) + '@vitest/runner': 4.1.6 + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) 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)': + '@storybook/addon-vitest@9.1.20(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6(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.6))(@vitest/runner@4.1.6)(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.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 1.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -18481,10 +18538,10 @@ snapshots: 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)) 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.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/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) - '@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.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.6(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.6) + '@vitest/browser-playwright': 4.1.6(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.6) + '@vitest/runner': 4.1.6 + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) transitivePeerDependencies: - react - react-dom @@ -19023,13 +19080,13 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 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/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@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@4.1.2)': + '@vitest/browser-playwright@4.1.6(playwright@1.59.0)(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@4.1.6)': dependencies: - '@vitest/browser': 4.1.2(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@4.1.2) - '@vitest/mocker': 4.1.2(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/browser': 4.1.6(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@4.1.6) + '@vitest/mocker': 4.1.6(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)) playwright: 1.59.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) transitivePeerDependencies: - bufferutil - msw @@ -19037,29 +19094,29 @@ 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.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/browser-playwright@4.1.6(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.6)': 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) - '@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)) + '@vitest/browser': 4.1.6(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.6) + '@vitest/mocker': 4.1.6(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)) 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.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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) 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@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@4.1.2)': + '@vitest/browser@4.1.6(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@4.1.6)': 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@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/utils': 4.1.2 + '@vitest/mocker': 4.1.6(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/utils': 4.1.6 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@22.19.15)(@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@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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -19068,16 +19125,16 @@ 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.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/browser@4.1.6(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.6)': 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.27.4)(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 + '@vitest/mocker': 4.1.6(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/utils': 4.1.6 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.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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -19085,7 +19142,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-istanbul@4.1.2(vitest@4.1.2)': + '@vitest/coverage-istanbul@4.1.6(vitest@4.1.6)': dependencies: '@babel/core': 7.29.0 '@istanbuljs/schema': 0.1.3 @@ -19097,7 +19154,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) transitivePeerDependencies: - supports-color @@ -19109,12 +19166,12 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.2': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 tinyrainbow: 3.1.0 @@ -19126,17 +19183,17 @@ snapshots: optionalDependencies: 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/mocker@4.1.2(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.6(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))': dependencies: - '@vitest/spy': 4.1.2 + '@vitest/spy': 4.1.6 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@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.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.6(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 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -19146,19 +19203,19 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.2': + '@vitest/pretty-format@4.1.6': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.2': + '@vitest/runner@4.1.6': dependencies: - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.6 pathe: 2.0.3 - '@vitest/snapshot@4.1.2': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 @@ -19166,7 +19223,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.2': {} + '@vitest/spy@4.1.6': {} '@vitest/utils@3.2.4': dependencies: @@ -19174,9 +19231,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.2': + '@vitest/utils@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.2 + '@vitest/pretty-format': 4.1.6 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -26502,15 +26559,15 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@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@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@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)): 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@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/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 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(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/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -26527,20 +26584,21 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 22.19.15 - '@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@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@4.1.2) + '@vitest/browser-playwright': 4.1.6(playwright@1.59.0)(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@4.1.6) + '@vitest/coverage-istanbul': 4.1.6(vitest@4.1.6) 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)): + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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 - '@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)) - '@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 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(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/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -26557,7 +26615,8 @@ snapshots: 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.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/browser-playwright': 4.1.6(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.6) + '@vitest/coverage-istanbul': 4.1.6(vitest@4.1.6) jsdom: 26.1.0 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9238556fd..9494ac83b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: '@serenity-js/cucumber': 3.42.2 '@serenity-js/serenity-bdd': 3.42.2 '@types/node': ^22.19.5 - '@vitest/coverage-istanbul': 4.1.2 + '@vitest/coverage-istanbul': 4.1.6 antd: 6.3.5 archunit: ^2.1.63 esbuild: 0.27.4 @@ -37,7 +37,7 @@ catalog: typescript: 6.0.3 "@typescript/native-preview": 7.0.0-dev.20260428.1 vite: 8.0.5 - vitest: 4.1.2 + vitest: 4.1.6 vite-plugin-node-polyfills: ^0.28.0 auditConfig: @@ -59,6 +59,8 @@ allowBuilds: snyk: true overrides: + '@vitest/browser': 4.1.6 + '@vitest/browser-playwright': 4.1.6 axios: 1.16.0 follow-redirects: ^1.16.0 vite: "catalog:" From 865a5c34300d72e90e5e93210b4ccb4ff0ed27b4 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Tue, 2 Jun 2026 15:37:01 -0400 Subject: [PATCH 3/7] reviewed changes on local with opus 4.8, made some adjustments from jsdom to happydom, and overall some code cleanliness changes --- packages/cellix/serenity-framework/README.md | 47 +++-- .../cellix/serenity-framework/manifest.md | 18 +- .../cellix/serenity-framework/package.json | 53 +++--- .../src/clients/graphql-client.ts | 12 +- .../serenity-framework/src/cucumber/hooks.ts | 4 - .../src/{jsdom => dom}/asset-loader-hooks.ts | 4 +- .../src/{jsdom => dom}/css-module-types.d.ts | 0 .../src/{jsdom => dom}/css-modules.ts | 0 .../{jsdom => dom}/register-asset-loader.ts | 2 +- .../src/dom/render-in-dom.ts | 116 +++++++++++++ .../serenity-framework/src/dom/setup.ts | 28 +++ .../src/infrastructure/api.ts | 11 -- .../src/infrastructure/e2e.ts | 9 - .../src/infrastructure/index.ts | 24 ++- .../serenity-framework/src/jsdom/jsdom.d.ts | 6 - .../src/jsdom/react-render.ts | 47 ----- .../serenity-framework/src/jsdom/setup.ts | 160 ------------------ .../{jsdom-adapter.ts => dom-adapter.ts} | 33 ++-- .../src/pages/page-adapter.ts | 2 +- .../src/servers/api-test-server.ts | 13 -- .../src/servers/auth-test-server.ts | 12 -- .../src/servers/azurite-test-server.ts | 13 -- .../serenity-framework/src/servers/index.ts | 10 -- .../src/servers/server-group.test.ts | 45 ----- .../src/servers/test-server-group.ts | 54 ------ .../src/servers/ui-portal-test-server.ts | 13 -- .../src/shared/shared-infrastructure.ts | 4 +- .../acceptance-ui/package.json | 6 +- .../authentication/notes/header-notes.ts | 2 + .../step-definitions/header-login.steps.tsx | 106 ++++-------- .../tasks/click-header-sign-in.ts | 24 +-- .../community/notes/community-notes.ts | 1 - .../questions/community-error-message.ts | 4 - .../create-community.steps.tsx | 110 +++--------- .../community/tasks/create-community.ts | 29 ++-- .../src/shared/cucumber-lifecycle-hooks.ts | 9 +- .../src/step-definitions/index.ts | 1 - .../acceptance-ui/src/world.ts | 58 ++----- .../acceptance-ui/tsconfig.json | 2 +- .../step-definitions/header-login.steps.ts | 2 +- .../src/shared/shared-infrastructure.ts | 14 +- .../src/shared/test-server-factories.ts | 22 +-- pnpm-lock.yaml | 150 ++++++++++------ 43 files changed, 493 insertions(+), 787 deletions(-) delete mode 100644 packages/cellix/serenity-framework/src/cucumber/hooks.ts rename packages/cellix/serenity-framework/src/{jsdom => dom}/asset-loader-hooks.ts (90%) rename packages/cellix/serenity-framework/src/{jsdom => dom}/css-module-types.d.ts (100%) rename packages/cellix/serenity-framework/src/{jsdom => dom}/css-modules.ts (100%) rename packages/cellix/serenity-framework/src/{jsdom => dom}/register-asset-loader.ts (75%) create mode 100644 packages/cellix/serenity-framework/src/dom/render-in-dom.ts create mode 100644 packages/cellix/serenity-framework/src/dom/setup.ts delete mode 100644 packages/cellix/serenity-framework/src/infrastructure/api.ts delete mode 100644 packages/cellix/serenity-framework/src/infrastructure/e2e.ts delete mode 100644 packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts delete mode 100644 packages/cellix/serenity-framework/src/jsdom/react-render.ts delete mode 100644 packages/cellix/serenity-framework/src/jsdom/setup.ts rename packages/cellix/serenity-framework/src/pages/adapters/{jsdom-adapter.ts => dom-adapter.ts} (84%) delete mode 100644 packages/cellix/serenity-framework/src/servers/api-test-server.ts delete mode 100644 packages/cellix/serenity-framework/src/servers/auth-test-server.ts delete mode 100644 packages/cellix/serenity-framework/src/servers/azurite-test-server.ts delete mode 100644 packages/cellix/serenity-framework/src/servers/server-group.test.ts delete mode 100644 packages/cellix/serenity-framework/src/servers/test-server-group.ts delete mode 100644 packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts diff --git a/packages/cellix/serenity-framework/README.md b/packages/cellix/serenity-framework/README.md index 8490ef0e5..abacc7c2f 100644 --- a/packages/cellix/serenity-framework/README.md +++ b/packages/cellix/serenity-framework/README.md @@ -6,7 +6,7 @@ This package is intentionally app-agnostic. It provides adapters, a generic Sere ## Page adapters -Page objects should depend on `PageAdapter`, not directly on jsdom or Playwright: +Page objects should depend on `PageAdapter`, not directly on happy-dom or Playwright: ```ts import { AdapterBackedPageObject, type PageAdapter } from '@cellix/serenity-framework/pages'; @@ -26,7 +26,7 @@ class CommunityPage extends AdapterBackedPageObject { Use the runtime-specific adapter at the edge of the test package: ```ts -import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; ``` @@ -36,9 +36,9 @@ Use generic server descriptors for app-specific processes, then load them into t ```ts import { E2EInfrastructure } from '@cellix/serenity-framework/infrastructure/e2e'; -import { ApiTestServer, AuthTestServer, AzuriteTestServer, UiPortalTestServer } from '@cellix/serenity-framework/servers'; +import { ProcessTestServer } from '@cellix/serenity-framework/servers'; -const communityPortal = new UiPortalTestServer({ +const communityPortal = new ProcessTestServer({ portalName: 'community', cwd: '/repo/apps/ui-community', getUrl: () => 'https://community.localhost:1355', @@ -51,7 +51,7 @@ export const infrastructure = E2EInfrastructure azuriteServer, authServer, createApiServer: ({ getMongoConnectionString }) => - new ApiTestServer({ + new ProcessTestServer({ serverName: 'Api', executable: 'pnpm', spawnArgs: ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'], @@ -115,17 +115,40 @@ export const ApiWorld = registerManagedSerenityWorld({ }); ``` -## jsdom helpers +## DOM (happy-dom) helpers -Component-level acceptance tests can import framework jsdom setup and asset-loader hooks instead of carrying per-suite copies: +Component-level acceptance tests run against an in-process DOM provided by +happy-dom. Preload the DOM setup and asset-loader hooks before any module that +imports `react-dom`, so React binds its event system to the happy-dom +environment. Both run as Node `--import` preloads, which is order-independent: ```sh -NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js +NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' cucumber-js ``` +Include `@cellix/serenity-framework/src/dom/css-module-types.d.ts` in tsconfig +when component imports include CSS modules. + +## Rendering components through actors + +Give actors the `RenderInDom` ability and let page objects read their root +element from the actor, instead of threading a container through world state or +task parameters. This is the in-process DOM counterpart to a browser +`BrowseTheWeb` ability, so component acceptance tests and browser E2E tests share +the same actor-centric shape. The ability unmounts the rendered tree when the +scenario ends. + ```ts -import '@cellix/serenity-framework/jsdom/setup'; -/// Include `@cellix/serenity-framework/src/jsdom/css-module-types.d.ts` -/// in tsconfig when component imports include CSS modules. -import { mountComponent, unmountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { SerenityCast } from '@cellix/serenity-framework/serenity'; + +// cast: grant every actor the ability +new SerenityCast({ useNotepad: true, abilities: [() => new RenderInDom()] }); + +// Given: render through the actor +await actor.attemptsTo(Render.component(, { wrapper: withProviders() })); + +// task/question: build the page object from the actor's container +const page = new LoginPage(new DomPageAdapter(RenderInDom.as(actor).container)); ``` diff --git a/packages/cellix/serenity-framework/manifest.md b/packages/cellix/serenity-framework/manifest.md index 11d6563c4..0e2f58705 100644 --- a/packages/cellix/serenity-framework/manifest.md +++ b/packages/cellix/serenity-framework/manifest.md @@ -8,13 +8,13 @@ Provide reusable Serenity/JS, Cucumber, page-adapter, and test-server framework This package owns generic verification infrastructure only: -- Serenity task, cast, and browser-ability primitives +- Serenity task, cast, browser-ability, and in-process DOM render-ability primitives - Cucumber data-table, lifecycle, screenshot, and managed-world helpers -- Runtime-agnostic page adapter contracts and jsdom/Playwright adapter implementations -- jsdom globals, CSS module declarations, asset-loader hooks, and generic React render helpers for component acceptance tests +- Runtime-agnostic page adapter contracts and in-process DOM (happy-dom) / Playwright adapter implementations +- DOM globals (via happy-dom), CSS module declarations, asset-loader hooks, and generic React render helpers for component acceptance tests - Adapter-backed page-object base contracts - Timeout utilities -- Configurable process, UI portal, Apollo GraphQL, Azurite, Mongo memory, and server-group lifecycle utilities +- Configurable process, Apollo GraphQL, and Mongo memory server lifecycle utilities - API acceptance and browser E2E infrastructure managers that create MongoDB from options and compose consumer-owned server factories ## Non-goals @@ -29,13 +29,13 @@ This package owns generic verification infrastructure only: - `@cellix/serenity-framework/cucumber`: `ActorName`, `GherkinDataTable`, lifecycle hook helpers - `@cellix/serenity-framework/cucumber/screenshot`: browser screenshot-on-failure hook helpers - `@cellix/serenity-framework/pages`: adapter contracts and page-object base types -- `@cellix/serenity-framework/pages/jsdom`: `JsdomPageAdapter` +- `@cellix/serenity-framework/pages/dom`: `DomPageAdapter` - `@cellix/serenity-framework/pages/playwright`: `PlaywrightPageAdapter` - `@cellix/serenity-framework/clients/graphql`: `GraphQLClient` -- `@cellix/serenity-framework/jsdom/setup`: jsdom global bootstrap side-effect module -- `@cellix/serenity-framework/jsdom/register-asset-loader`: jsdom asset-loader registration side-effect module -- `@cellix/serenity-framework/jsdom/react-render`: generic React mount/unmount helpers -- `@cellix/serenity-framework/jsdom/css-modules`: package-owned CSS module declaration target +- `@cellix/serenity-framework/dom/setup`: DOM global bootstrap side-effect module (happy-dom) +- `@cellix/serenity-framework/dom/register-asset-loader`: asset-loader registration side-effect module +- `@cellix/serenity-framework/dom/render-in-dom`: `RenderInDom` ability and `Render` interaction for rendering components through actors +- `@cellix/serenity-framework/dom/css-modules`: package-owned CSS module declaration target - `@cellix/serenity-framework/serenity`: `TaskStep`, `SerenityCast` - `@cellix/serenity-framework/serenity/browser`: `BrowseTheWeb` - `@cellix/serenity-framework/infrastructure/api`: API acceptance infrastructure manager with MongoDB options, optional Mongoose service management, and an API server factory diff --git a/packages/cellix/serenity-framework/package.json b/packages/cellix/serenity-framework/package.json index fe50b41f5..50f49683c 100644 --- a/packages/cellix/serenity-framework/package.json +++ b/packages/cellix/serenity-framework/package.json @@ -7,7 +7,7 @@ "types": "dist/index.d.ts", "files": [ "dist", - "src/jsdom/css-module-types.d.ts" + "src/dom/css-module-types.d.ts" ], "exports": { ".": { @@ -30,10 +30,6 @@ "types": "./dist/cucumber/gherkin-data-table.d.ts", "default": "./dist/cucumber/gherkin-data-table.js" }, - "./cucumber/hooks": { - "types": "./dist/cucumber/hooks.d.ts", - "default": "./dist/cucumber/hooks.js" - }, "./cucumber/screenshot": { "types": "./dist/cucumber/screenshot-hooks.d.ts", "default": "./dist/cucumber/screenshot-hooks.js" @@ -47,44 +43,44 @@ "default": "./dist/infrastructure/index.js" }, "./infrastructure/api": { - "types": "./dist/infrastructure/api.d.ts", - "default": "./dist/infrastructure/api.js" + "types": "./dist/infrastructure/api-infrastructure.d.ts", + "default": "./dist/infrastructure/api-infrastructure.js" }, "./infrastructure/e2e": { - "types": "./dist/infrastructure/e2e.d.ts", - "default": "./dist/infrastructure/e2e.js" + "types": "./dist/infrastructure/e2e-infrastructure.d.ts", + "default": "./dist/infrastructure/e2e-infrastructure.js" }, "./pages": { "types": "./dist/pages/index.d.ts", "default": "./dist/pages/index.js" }, - "./pages/jsdom": { - "types": "./dist/pages/adapters/jsdom-adapter.d.ts", - "default": "./dist/pages/adapters/jsdom-adapter.js" + "./pages/dom": { + "types": "./dist/pages/adapters/dom-adapter.d.ts", + "default": "./dist/pages/adapters/dom-adapter.js" }, "./pages/playwright": { "types": "./dist/pages/adapters/playwright-adapter.d.ts", "default": "./dist/pages/adapters/playwright-adapter.js" }, - "./jsdom/setup": { - "types": "./dist/jsdom/setup.d.ts", - "default": "./dist/jsdom/setup.js" + "./dom/setup": { + "types": "./dist/dom/setup.d.ts", + "default": "./dist/dom/setup.js" }, - "./jsdom/register-asset-loader": { - "types": "./dist/jsdom/register-asset-loader.d.ts", - "default": "./dist/jsdom/register-asset-loader.js" + "./dom/register-asset-loader": { + "types": "./dist/dom/register-asset-loader.d.ts", + "default": "./dist/dom/register-asset-loader.js" }, - "./jsdom/asset-loader-hooks": { - "types": "./dist/jsdom/asset-loader-hooks.d.ts", - "default": "./dist/jsdom/asset-loader-hooks.js" + "./dom/asset-loader-hooks": { + "types": "./dist/dom/asset-loader-hooks.d.ts", + "default": "./dist/dom/asset-loader-hooks.js" }, - "./jsdom/react-render": { - "types": "./dist/jsdom/react-render.d.ts", - "default": "./dist/jsdom/react-render.js" + "./dom/render-in-dom": { + "types": "./dist/dom/render-in-dom.d.ts", + "default": "./dist/dom/render-in-dom.js" }, - "./jsdom/css-modules": { - "types": "./src/jsdom/css-module-types.d.ts", - "default": "./dist/jsdom/css-modules.js" + "./dom/css-modules": { + "types": "./src/dom/css-module-types.d.ts", + "default": "./dist/dom/css-modules.js" }, "./serenity": { "types": "./dist/serenity/index.d.ts", @@ -119,10 +115,11 @@ "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", "@cucumber/cucumber": "catalog:", "@cucumber/messages": "catalog:", + "@happy-dom/global-registrator": "^20.9.0", "@serenity-js/core": "catalog:", "graphql": "catalog:", "graphql-depth-limit": "^1.1.0", - "jsdom": "catalog:", + "happy-dom": "^20.9.0", "mongodb": "catalog:" }, "peerDependencies": { diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.ts index 49d851451..7aaa5076f 100644 --- a/packages/cellix/serenity-framework/src/clients/graphql-client.ts +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.ts @@ -76,14 +76,22 @@ export class GraphQLClient extends Ability { body: JSON.stringify({ query, variables }), }); - const result = (await response.json()) as GraphQLResponse; + let result: GraphQLResponse; + try { + result = (await response.json()) as GraphQLResponse; + } catch (parseError) { + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`); + } + throw parseError; + } if (result.errors?.length) { throw new Error(result.errors.map((error) => error.message ?? 'Unknown error').join('; ')); } if (!response.ok) { - throw new Error(`GraphQL error: ${response.status} ${response.statusText}`); + throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`); } return result; diff --git a/packages/cellix/serenity-framework/src/cucumber/hooks.ts b/packages/cellix/serenity-framework/src/cucumber/hooks.ts deleted file mode 100644 index bcf61416b..000000000 --- a/packages/cellix/serenity-framework/src/cucumber/hooks.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type { WorldLifecycleHooks } from './lifecycle-hooks.ts'; -export { registerWorldLifecycleHooks } from './lifecycle-hooks.ts'; -export type { ScreenshotOnFailureOptions } from './screenshot-hooks.ts'; -export { registerScreenshotOnFailureHook } from './screenshot-hooks.ts'; diff --git a/packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts similarity index 90% rename from packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts rename to packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts index eab193590..f6652fb22 100644 --- a/packages/cellix/serenity-framework/src/jsdom/asset-loader-hooks.ts +++ b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts @@ -2,7 +2,7 @@ * ESM loader hooks that intercept CSS, image, and other non-JS imports so * they resolve to empty modules instead of throwing in Node.js. * - * Usage: `NODE_OPTIONS='--import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js` + * Usage: `NODE_OPTIONS='--import @cellix/serenity-framework/dom/register-asset-loader' cucumber-js` */ const ASSET_RE = /\.(css|less|scss|sass|svg|png|jpe?g|gif|webp|woff2?|ttf|eot|ico)$/i; @@ -26,7 +26,7 @@ export interface AssetLoaderResolveResult { export type NextAssetLoaderResolve = (specifier: string, context: AssetLoaderResolveContext) => Promise; /** - * Resolve CSS, image, font, and Ant Design ESM imports for jsdom acceptance tests. + * Resolve CSS, image, font, and Ant Design ESM imports for component acceptance tests. * * Asset imports resolve to empty JavaScript modules. Ant Design `antd/es/*` * imports are redirected to `antd/lib/*` when possible because many Node-based diff --git a/packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts b/packages/cellix/serenity-framework/src/dom/css-module-types.d.ts similarity index 100% rename from packages/cellix/serenity-framework/src/jsdom/css-module-types.d.ts rename to packages/cellix/serenity-framework/src/dom/css-module-types.d.ts diff --git a/packages/cellix/serenity-framework/src/jsdom/css-modules.ts b/packages/cellix/serenity-framework/src/dom/css-modules.ts similarity index 100% rename from packages/cellix/serenity-framework/src/jsdom/css-modules.ts rename to packages/cellix/serenity-framework/src/dom/css-modules.ts diff --git a/packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts b/packages/cellix/serenity-framework/src/dom/register-asset-loader.ts similarity index 75% rename from packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts rename to packages/cellix/serenity-framework/src/dom/register-asset-loader.ts index d13d50cd8..9601fa9eb 100644 --- a/packages/cellix/serenity-framework/src/jsdom/register-asset-loader.ts +++ b/packages/cellix/serenity-framework/src/dom/register-asset-loader.ts @@ -3,7 +3,7 @@ * without errors in Node.js. * * Use via NODE_OPTIONS: - * `NODE_OPTIONS='--import @cellix/serenity-framework/jsdom/register-asset-loader'`. + * `NODE_OPTIONS='--import @cellix/serenity-framework/dom/register-asset-loader'`. */ import { register } from 'node:module'; diff --git a/packages/cellix/serenity-framework/src/dom/render-in-dom.ts b/packages/cellix/serenity-framework/src/dom/render-in-dom.ts new file mode 100644 index 000000000..066c7acc6 --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/render-in-dom.ts @@ -0,0 +1,116 @@ +import { Ability, type Discardable, Interaction } from '@serenity-js/core'; +import { type RenderResult, render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +/** Wraps a rendered React element before it is mounted (providers, routing, theme). */ +export type ReactRenderWrapper = (children: ReactElement) => ReactElement; + +/** Options accepted when rendering a component through {@link RenderInDom}. */ +export interface RenderComponentOptions { + /** Optional wrapper supplying providers such as routing, theme, or GraphQL. */ + wrapper?: ReactRenderWrapper; +} + +/** + * Serenity ability that renders React components into the active in-process DOM + * and owns the resulting container for the lifetime of a scenario. + * + * This is the in-process DOM counterpart to a browser `BrowseTheWeb` ability: + * page objects obtain their root element from the actor's ability rather than + * receiving a container through world state or task parameters, so component + * acceptance tests and browser E2E tests share the same actor-centric shape. + * + * The ability is {@link Discardable}; Serenity unmounts the rendered tree when + * the actor is dismissed. Suites that do not rely on Serenity actor dismissal + * can also call {@link unmount} from a test-runner teardown hook. + * + * @example + * ```ts + * const cast = new SerenityCast({ useNotepad: true, abilities: [() => new RenderInDom()] }); + * + * // in a step: + * await actor.attemptsTo(Render.component(, { wrapper: withProviders() })); + * const page = new LoginPage(new DomPageAdapter(RenderInDom.as(actor).container)); + * ``` + */ +export class RenderInDom extends Ability implements Discardable { + private rendered: RenderResult | undefined; + + /** + * Create a render ability with no component mounted yet. + * + * Declared explicitly to widen Serenity's `protected` ability constructor to + * `public`, so the cast can instantiate it and `RenderInDom.as(actor)` resolves. + */ + // biome-ignore lint/complexity/noUselessConstructor: widens the inherited protected constructor to public. + constructor() { + super(); + } + + /** + * Render a React element, unmounting any element previously rendered by this + * ability so scenarios do not leak DOM state. + * + * @param ui React element to render. + * @param options Optional provider wrapper. + * @returns Testing Library render result for the mounted component. + */ + render(ui: ReactElement, options?: RenderComponentOptions): RenderResult { + this.unmount(); + this.rendered = render(options?.wrapper ? options.wrapper(ui) : ui); + return this.rendered; + } + + /** + * Root element that scopes all page-object selections for the current render. + * + * @throws Error when no component has been rendered yet. + */ + get container(): HTMLElement { + return this.currentResult().container; + } + + /** + * Testing Library render result for the current render. + * + * @throws Error when no component has been rendered yet. + */ + get result(): RenderResult { + return this.currentResult(); + } + + /** Unmount the current render, if one exists. */ + unmount(): void { + this.rendered?.unmount(); + this.rendered = undefined; + } + + /** Unmount on actor dismissal. Invoked by Serenity when the scene finishes. */ + discard(): void { + this.unmount(); + } + + private currentResult(): RenderResult { + if (!this.rendered) { + throw new Error('RenderInDom: no component has been rendered — did the Given step run?'); + } + return this.rendered; + } +} + +/** + * Screenplay interactions for rendering components through {@link RenderInDom}. + */ +export const Render = { + /** + * Render a React component into the active DOM via the actor's + * {@link RenderInDom} ability. + * + * @param ui React element to render. + * @param options Optional provider wrapper. + */ + component: (ui: ReactElement, options?: RenderComponentOptions): Interaction => + Interaction.where('#actor renders a component', (actor) => { + RenderInDom.as(actor).render(ui, options); + }), +} as const; diff --git a/packages/cellix/serenity-framework/src/dom/setup.ts b/packages/cellix/serenity-framework/src/dom/setup.ts new file mode 100644 index 000000000..a88bc972c --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/setup.ts @@ -0,0 +1,28 @@ +/** + * DOM environment bootstrap for component-level acceptance tests. + * + * Registers a complete set of browser globals — `window`, `document`, + * `navigator`, the DOM constructor classes, and modern layout APIs such as + * `matchMedia`, `ResizeObserver`, and `IntersectionObserver` — onto the Node + * global object via + * {@link https://github.com/capricorn86/happy-dom | happy-dom}'s global + * registrator. + * + * happy-dom implements the layout/visual APIs that Ant Design and React Router + * touch at import or render time, so unlike jsdom this setup needs no manual + * polyfills. + * + * Load this module for its side effects before any module that imports + * `react-dom`, so React binds its event system to the happy-dom environment. + * Prefer a Node `--import` preload, which is order-independent: + * + * ```sh + * NODE_OPTIONS='--import @cellix/serenity-framework/dom/setup' cucumber-js + * ``` + * + * @packageDocumentation + */ + +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +GlobalRegistrator.register({ url: 'http://localhost:3000' }); diff --git a/packages/cellix/serenity-framework/src/infrastructure/api.ts b/packages/cellix/serenity-framework/src/infrastructure/api.ts deleted file mode 100644 index d4cc0bb9b..000000000 --- a/packages/cellix/serenity-framework/src/infrastructure/api.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - ApiInfrastructureMongooseOptions, - ApiInfrastructureOptions, - ApiInfrastructureState, - ApiMongoServerFactory, - ApiServerFactory, - ApiServerFactoryContext, - ManagedMongooseConnection, - ManagedMongooseService, -} from './api-infrastructure.ts'; -export { ApiInfrastructure } from './api-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/e2e.ts b/packages/cellix/serenity-framework/src/infrastructure/e2e.ts deleted file mode 100644 index 3e93f3921..000000000 --- a/packages/cellix/serenity-framework/src/infrastructure/e2e.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type { - E2EApiServerFactory, - E2EInfrastructureCallbackContext, - E2EInfrastructureOptions, - E2EInfrastructureState, - E2EMongoServerFactory, - UiPortalRegistration, -} from './e2e-infrastructure.ts'; -export { E2EInfrastructure } from './e2e-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/infrastructure/index.ts b/packages/cellix/serenity-framework/src/infrastructure/index.ts index 5c4ee90c3..5334f34c0 100644 --- a/packages/cellix/serenity-framework/src/infrastructure/index.ts +++ b/packages/cellix/serenity-framework/src/infrastructure/index.ts @@ -1,4 +1,20 @@ -export type { ApiInfrastructureOptions, ApiInfrastructureState } from './api.ts'; -export { ApiInfrastructure } from './api.ts'; -export type { E2EInfrastructureCallbackContext, E2EInfrastructureOptions, E2EInfrastructureState, UiPortalRegistration } from './e2e.ts'; -export { E2EInfrastructure } from './e2e.ts'; +export type { + ApiInfrastructureMongooseOptions, + ApiInfrastructureOptions, + ApiInfrastructureState, + ApiMongoServerFactory, + ApiServerFactory, + ApiServerFactoryContext, + ManagedMongooseConnection, + ManagedMongooseService, +} from './api-infrastructure.ts'; +export { ApiInfrastructure } from './api-infrastructure.ts'; +export type { + E2EApiServerFactory, + E2EInfrastructureCallbackContext, + E2EInfrastructureOptions, + E2EInfrastructureState, + E2EMongoServerFactory, + UiPortalRegistration, +} from './e2e-infrastructure.ts'; +export { E2EInfrastructure } from './e2e-infrastructure.ts'; diff --git a/packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts b/packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts deleted file mode 100644 index b8f49557a..000000000 --- a/packages/cellix/serenity-framework/src/jsdom/jsdom.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'jsdom' { - export class JSDOM { - constructor(html?: string, options?: Record); - readonly [key: string]: Window & typeof globalThis; - } -} diff --git a/packages/cellix/serenity-framework/src/jsdom/react-render.ts b/packages/cellix/serenity-framework/src/jsdom/react-render.ts deleted file mode 100644 index fb1065fe2..000000000 --- a/packages/cellix/serenity-framework/src/jsdom/react-render.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type RenderResult, render } from '@testing-library/react'; -import type React from 'react'; - -/** Wraps a rendered React element before it is mounted. */ -export type ReactRenderWrapper = (children: React.ReactElement) => React.ReactElement; - -/** Options used by {@link mountComponent}. */ -export interface ReactMountOptions { - /** Optional wrapper used for providers such as routing, theme, or GraphQL. */ - wrapper?: ReactRenderWrapper; -} - -let rendered: RenderResult | null = null; - -/** - * Mount a React element into the active jsdom document. - * - * Any previously mounted element is unmounted first so component-level - * acceptance tests do not leak state between scenarios. - * - * @param ui React element to mount. - * @param options Optional provider wrapper. - * @returns Testing Library render result for the mounted component. - */ -export function mountComponent(ui: React.ReactElement, options?: ReactMountOptions): RenderResult { - unmountComponent(); - - rendered = render(options?.wrapper ? options.wrapper(ui) : ui); - return rendered; -} - -/** - * Unmount the currently mounted component, when one exists. - */ -export function unmountComponent(): void { - if (rendered) { - rendered.unmount(); - rendered = null; - } -} - -/** - * Return the current Testing Library render result. - */ -export function getRendered(): RenderResult | null { - return rendered; -} diff --git a/packages/cellix/serenity-framework/src/jsdom/setup.ts b/packages/cellix/serenity-framework/src/jsdom/setup.ts deleted file mode 100644 index 6b7116689..000000000 --- a/packages/cellix/serenity-framework/src/jsdom/setup.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * JSDOM environment setup — initialises global browser APIs that libraries like - * antd or React Router rely on at import time. - * - * Must be imported before any React component code runs. - */ - -import { JSDOM } from 'jsdom'; - -const dom = new JSDOM('
', { - url: 'http://localhost:3000', - pretendToBeVisual: true, -}); -// 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; - -/** - * Safely assign a global — falls back to Object.defineProperty when the - * property is read-only (e.g. `navigator` in Node 22). - */ -const safeAssign = (name: string, value: unknown) => { - try { - g[name] = value; - } catch { - Object.defineProperty(globalThis, name, { - value, - writable: true, - configurable: true, - }); - } -}; - -safeAssign('window', domGlobal); -safeAssign('document', domGlobal.document); -safeAssign('navigator', domGlobal.navigator); -safeAssign('HTMLElement', domGlobal.HTMLElement); -safeAssign('HTMLInputElement', domGlobal.HTMLInputElement); -safeAssign('HTMLTextAreaElement', domGlobal.HTMLTextAreaElement); -safeAssign('HTMLFormElement', domGlobal.HTMLFormElement); -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); -safeAssign('CustomEvent', domGlobal.CustomEvent); -safeAssign('KeyboardEvent', domGlobal.KeyboardEvent); -safeAssign('MouseEvent', domGlobal.MouseEvent); -safeAssign('getComputedStyle', domGlobal.getComputedStyle); -safeAssign('requestAnimationFrame', (cb: () => void) => setTimeout(cb, 0)); -safeAssign('cancelAnimationFrame', (id: number) => clearTimeout(id)); -safeAssign('location', domGlobal.location); -safeAssign('history', domGlobal.history); -safeAssign('MutationObserver', domGlobal.MutationObserver); -safeAssign('URL', domGlobal.URL); -safeAssign('URLSearchParams', domGlobal.URLSearchParams); -safeAssign('SubmitEvent', domGlobal.SubmitEvent); - -/* --- Stubs for APIs not supported by jsdom --- */ - -domGlobal.matchMedia = - domGlobal.matchMedia || - (() => ({ - matches: false, - addListener: () => { - /* noop stub */ - }, - removeListener: () => { - /* noop stub */ - }, - addEventListener: () => { - /* noop stub */ - }, - removeEventListener: () => { - /* noop stub */ - }, - dispatchEvent: () => false, - media: '', - onchange: null, - })); - -g.ResizeObserver = - g.ResizeObserver || - class { - observe() { - /* noop stub */ - } - unobserve() { - /* noop stub */ - } - disconnect() { - /* noop stub */ - } - }; - -g.IntersectionObserver = - g.IntersectionObserver || - class { - observe() { - /* noop stub */ - } - unobserve() { - /* noop stub */ - } - disconnect() { - /* noop stub */ - } - }; - -domGlobal.scrollTo = - domGlobal.scrollTo || - (() => { - /* noop stub */ - }); -domGlobal.scroll = - domGlobal.scroll || - (() => { - /* noop stub */ - }); -domGlobal.resizeTo = - domGlobal.resizeTo || - (() => { - /* noop stub */ - }); - -domGlobal.getComputedStyle = - domGlobal.getComputedStyle || - (() => ({ - getPropertyValue: () => '', - })); - -g.document.elementFromPoint = g.document.elementFromPoint || (() => null); -g.document.elementsFromPoint = g.document.elementsFromPoint || (() => []); - -// jsdom does not implement form.requestSubmit(), but clicking a submit button -// uses it internally. Dispatch a cancelable submit event so form handlers can -// drive test state the same way a browser-backed form flow would. -g.HTMLFormElement.prototype.requestSubmit = function requestSubmit(submitter?: HTMLElement) { - if (typeof this.checkValidity === 'function' && !this.checkValidity()) { - return; - } - - const submitEvent = new g.Event('submit', { - bubbles: true, - cancelable: true, - }); - - Object.defineProperty(submitEvent, 'submitter', { - value: submitter ?? null, - configurable: true, - }); - - this.dispatchEvent(submitEvent); -}; diff --git a/packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts similarity index 84% rename from packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts rename to packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts index c82c7c412..8ce76571a 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/jsdom-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts @@ -25,9 +25,9 @@ function findLabelControl(container: Element, text: string): Element | null { } /** - * Element handle backed by a jsdom `Element`. + * Element handle backed by an in-process DOM `Element` (happy-dom or jsdom). */ -export class JsdomElementHandle implements ElementHandle { +export class DomElementHandle implements ElementHandle { /** * @param element Element to adapt, or `null` for a missing selection. */ @@ -97,35 +97,36 @@ export class JsdomElementHandle implements ElementHandle { querySelector(selector: string): Promise { const child = this.element?.querySelector(selector) ?? null; - return Promise.resolve(child ? new JsdomElementHandle(child) : null); + return Promise.resolve(child ? new DomElementHandle(child) : null); } querySelectorAll(selector: string): Promise { if (!this.element) { return Promise.resolve([]); } - return Promise.resolve(Array.from(this.element.querySelectorAll(selector)).map((element) => new JsdomElementHandle(element))); + return Promise.resolve(Array.from(this.element.querySelectorAll(selector)).map((element) => new DomElementHandle(element))); } } /** - * Page adapter backed by a jsdom container element. + * Page adapter backed by an in-process DOM container element. * - * Use this adapter in component-level Cucumber tests that render React into - * jsdom while reusing the same page-object classes used by browser E2E tests. + * Use this adapter in component-level Cucumber tests that render React into an + * in-process DOM (happy-dom or jsdom) while reusing the same page-object + * classes used by browser E2E tests. */ -export class JsdomPageAdapter implements PageAdapter { +export class DomPageAdapter implements PageAdapter { /** * @param container Root element that scopes all selections for this page. */ constructor(private readonly container: Element) {} getByPlaceholder(text: string): ElementHandle { - return new JsdomElementHandle(this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`)); + return new DomElementHandle(this.container.querySelector(`[placeholder="${text}"], [placeholder*="${text}"]`)); } getByLabel(text: string): ElementHandle { - return new JsdomElementHandle(findLabelControl(this.container, text)); + return new DomElementHandle(findLabelControl(this.container, text)); } getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { @@ -154,18 +155,18 @@ export class JsdomPageAdapter implements PageAdapter { const ariaLabel = element.getAttribute('aria-label') ?? ''; return nameFilter instanceof RegExp ? nameFilter.test(textContent) || nameFilter.test(ariaLabel) : textContent.includes(nameFilter) || ariaLabel.includes(nameFilter); }); - return new JsdomElementHandle(match ?? null); + return new DomElementHandle(match ?? null); } - return new JsdomElementHandle(candidates[0] ?? null); + return new DomElementHandle(candidates[0] ?? null); } locator(selector: string): ElementHandle { - return new JsdomElementHandle(this.container.querySelector(selector)); + return new DomElementHandle(this.container.querySelector(selector)); } locatorAll(selector: string): Promise { - return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((element) => new JsdomElementHandle(element))); + return Promise.resolve(Array.from(this.container.querySelectorAll(selector)).map((element) => new DomElementHandle(element))); } getByText(text: string | RegExp, options?: { selector?: string }): ElementHandle { @@ -177,10 +178,10 @@ export class JsdomPageAdapter implements PageAdapter { const content = node.textContent ?? ''; const matches = text instanceof RegExp ? text.test(content) : content.includes(text); if (matches && node.parentElement) { - return new JsdomElementHandle(node.parentElement); + return new DomElementHandle(node.parentElement); } } - return new JsdomElementHandle(null); + return new DomElementHandle(null); } goto(url: string, _options?: PageNavigationOptions): Promise { diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts index 0889f07fb..de24f5a37 100644 --- a/packages/cellix/serenity-framework/src/pages/page-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -100,4 +100,4 @@ export interface PageAdapter { } /** Supported adapter runtime labels. */ -export type PageAdapterMode = 'jsdom' | 'playwright'; +export type PageAdapterMode = 'dom' | 'playwright'; diff --git a/packages/cellix/serenity-framework/src/servers/api-test-server.ts b/packages/cellix/serenity-framework/src/servers/api-test-server.ts deleted file mode 100644 index 09b927560..000000000 --- a/packages/cellix/serenity-framework/src/servers/api-test-server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; - -/** Options used by {@link ApiTestServer}. */ -export type ApiTestServerOptions = ProcessTestServerOptions; - -/** - * Process-backed API server for verification suites. - * - * The framework supplies lifecycle behavior only. Consumers provide every - * command, path, URL, readiness marker, environment value, and probe required - * by their application. - */ -export class ApiTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/auth-test-server.ts b/packages/cellix/serenity-framework/src/servers/auth-test-server.ts deleted file mode 100644 index 57891aabd..000000000 --- a/packages/cellix/serenity-framework/src/servers/auth-test-server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; - -/** Options used by {@link AuthTestServer}. */ -export type AuthTestServerOptions = ProcessTestServerOptions; - -/** - * Process-backed authentication server for verification suites. - * - * Consumers provide the complete descriptor so the framework remains ignorant - * of local auth tools, hostnames, ports, and startup commands. - */ -export class AuthTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts b/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts deleted file mode 100644 index b219c6700..000000000 --- a/packages/cellix/serenity-framework/src/servers/azurite-test-server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; - -/** Options used by {@link AzuriteTestServer}. */ -export type AzuriteTestServerOptions = ProcessTestServerOptions; - -/** - * Process-backed Azurite server for verification suites. - * - * All app-specific command, port, environment, and readiness details are - * supplied by the consumer so the framework package stays ignorant of local - * workspace conventions. - */ -export class AzuriteTestServer extends ProcessTestServer {} diff --git a/packages/cellix/serenity-framework/src/servers/index.ts b/packages/cellix/serenity-framework/src/servers/index.ts index 18a418b72..995ce4500 100644 --- a/packages/cellix/serenity-framework/src/servers/index.ts +++ b/packages/cellix/serenity-framework/src/servers/index.ts @@ -1,18 +1,8 @@ -export type { ApiTestServerOptions } from './api-test-server.ts'; -export { ApiTestServer } from './api-test-server.ts'; export type { ApolloGraphQLTestServerOptions } from './apollo-graphql-test-server.ts'; export { ApolloGraphQLTestServer } from './apollo-graphql-test-server.ts'; -export type { AuthTestServerOptions } from './auth-test-server.ts'; -export { AuthTestServer } from './auth-test-server.ts'; -export type { AzuriteTestServerOptions } from './azurite-test-server.ts'; -export { AzuriteTestServer } from './azurite-test-server.ts'; export type { MongoMemorySeedContext, MongoMemorySeedDataFunction, MongoMemoryTestServerOptions } from './mongo-memory-test-server.ts'; export { MongoMemoryTestServer } from './mongo-memory-test-server.ts'; export { createSpawnEnvironment } from './process-environment.ts'; export type { ProcessHealthProbe, ProcessTestServerOptions } from './process-test-server.ts'; export { ProcessTestServer } from './process-test-server.ts'; export type { SeedDataFunction, TestServer } from './test-server.ts'; -export type { TestServerGroupOptions } from './test-server-group.ts'; -export { TestServerGroup } from './test-server-group.ts'; -export type { UiPortalTestServerOptions } from './ui-portal-test-server.ts'; -export { UiPortalTestServer } from './ui-portal-test-server.ts'; diff --git a/packages/cellix/serenity-framework/src/servers/server-group.test.ts b/packages/cellix/serenity-framework/src/servers/server-group.test.ts deleted file mode 100644 index 81fb1659d..000000000 --- a/packages/cellix/serenity-framework/src/servers/server-group.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type TestServer, TestServerGroup } from './index.ts'; - -class RecordingServer implements TestServer { - private readonly name: string; - private readonly calls: string[]; - - constructor(name: string, calls: string[]) { - this.name = name; - this.calls = calls; - } - - start(): Promise { - this.calls.push(`start:${this.name}`); - return Promise.resolve(); - } - - stop(): Promise { - this.calls.push(`stop:${this.name}`); - return Promise.resolve(); - } - - isRunning(): boolean { - return false; - } - - getUrl(): string { - return `https://${this.name}.example.test`; - } -} - -describe('TestServerGroup', () => { - it('starts required servers before variable UI portals and stops in reverse groups', async () => { - const calls: string[] = []; - const group = new TestServerGroup({ - required: [new RecordingServer('api', calls), new RecordingServer('auth', calls)], - uiPortals: [new RecordingServer('community', calls), new RecordingServer('staff', calls)], - }); - - await group.start(); - await group.stop(); - - expect(calls).toEqual(['start:api', 'start:auth', 'start:community', 'start:staff', 'stop:staff', 'stop:community', 'stop:auth', 'stop:api']); - }); -}); diff --git a/packages/cellix/serenity-framework/src/servers/test-server-group.ts b/packages/cellix/serenity-framework/src/servers/test-server-group.ts deleted file mode 100644 index 349e4f438..000000000 --- a/packages/cellix/serenity-framework/src/servers/test-server-group.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { TestServer } from './test-server.ts'; - -/** Options used by {@link TestServerGroup}. */ -export interface TestServerGroupOptions { - /** Servers required for the system under test. */ - required: TestServer[]; - - /** Variable UI portal servers. */ - uiPortals?: TestServer[]; -} - -/** - * Starts and stops required servers plus any number of UI portal servers. - * - * Required servers start before UI portals. Shutdown runs in reverse order. - */ -export class TestServerGroup implements TestServer { - private readonly required: TestServer[]; - private readonly uiPortals: TestServer[]; - - /** - * @param options Required servers and optional UI portals. - */ - constructor(options: TestServerGroupOptions) { - this.required = options.required; - this.uiPortals = options.uiPortals ?? []; - } - - /** Start required servers, then all UI portal servers. */ - async start(): Promise { - await Promise.all(this.required.map((server) => server.start())); - await Promise.all(this.uiPortals.map((server) => server.start())); - } - - /** Stop UI portals, then required servers. */ - async stop(): Promise { - await Promise.all([...this.uiPortals].reverse().map((server) => server.stop().catch(() => undefined))); - await Promise.all([...this.required].reverse().map((server) => server.stop().catch(() => undefined))); - } - - /** Return whether any grouped server reports as running. */ - isRunning(): boolean { - return [...this.required, ...this.uiPortals].some((server) => server.isRunning()); - } - - /** - * Server groups do not expose a single URL. - * - * @throws Error always. - */ - getUrl(): string { - throw new Error('TestServerGroup does not expose a single URL'); - } -} diff --git a/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts b/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts deleted file mode 100644 index 44221eaee..000000000 --- a/packages/cellix/serenity-framework/src/servers/ui-portal-test-server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ProcessTestServer, type ProcessTestServerOptions } from './process-test-server.ts'; - -/** Options used by {@link UiPortalTestServer}. */ -export type UiPortalTestServerOptions = ProcessTestServerOptions; - -/** - * Generic UI portal server for browser E2E suites. - * - * Consumers create one instance per portal and provide every command, path, - * readiness marker, environment value, and URL. The framework intentionally - * does not default to any dev server, executable, or portal naming convention. - */ -export class UiPortalTestServer extends ProcessTestServer {} diff --git a/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts b/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts index 0531dba81..117fe20f1 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/shared-infrastructure.ts @@ -18,9 +18,7 @@ const infrastructure = ApiInfrastructure.using({ }, }); -interface InfrastructureState extends ApiInfrastructureState {} - -export function getState(): InfrastructureState { +export function getState(): ApiInfrastructureState { return infrastructure.getState(); } diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index 3bac51797..d0943ce3d 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -5,9 +5,9 @@ "private": true, "type": "module", "scripts": { - "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' cucumber-js", - "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' c8 cucumber-js", - "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/jsdom/register-asset-loader' c8 cucumber-js" + "test:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' cucumber-js", + "test:acceptance:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' c8 cucumber-js", + "test:coverage:acceptance": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import @cellix/serenity-framework/dom/register-asset-loader --import @cellix/serenity-framework/dom/setup' c8 cucumber-js" }, "dependencies": { "@apollo/client": "^3.13.9", diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts index 7b1b83168..670e14332 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/notes/header-notes.ts @@ -1,4 +1,6 @@ export interface HeaderUiNotes { + site: 'community' | 'staff'; + identityProviderUnreachable: boolean; 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 index 7f1348c34..a69c19805 100644 --- 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 @@ -1,112 +1,80 @@ -import { mountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; import { Given, Then, When } from '@cucumber/cucumber'; -import { actorCalled, notes } from '@serenity-js/core'; +import { actorCalled, actorInTheSpotlight, 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 { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; -import type { CellixUiWorld } from '../../../world.ts'; import type { HeaderUiNotes } from '../notes/header-notes.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; +async function visitSite(actorName: string, site: Site): Promise { + await actorCalled(actorName).attemptsTo( + notes().set('site', site), + notes().set('identityProviderUnreachable', false), + notes().set('signinRedirectCalled', false), + notes().set('consoleErrorCalled', false), + notes().set('fallbackInvoked', false), + ); } -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 community site', async (actorName: string) => { + await visitSite(actorName, 'community'); }); -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('{word} visits the staff site', async (actorName: string) => { + await visitSite(actorName, 'staff'); }); -Given('the identity provider is unreachable', function (this: CellixUiWorld) { - const state = getState(this); - state.identityProviderUnreachable = true; +Given('the identity provider is unreachable', async () => { + await actorInTheSpotlight().attemptsTo(notes().set('identityProviderUnreachable', true)); }); -When('{word} chooses to sign in', async function (this: CellixUiWorld, _actorName: string) { - const state = getState(this); +When('{word} chooses to sign in', async (actorName: string) => { + const actor = actorCalled(actorName); + const site = await actor.answer(notes().get('site')); + const identityProviderUnreachable = await actor.answer(notes().get('identityProviderUnreachable')); + let signinRedirectCalled = false; const signinRedirect = (): Promise => { - state.signinRedirectCalled = true; - if (state.identityProviderUnreachable) { - return Promise.reject(new Error('Simulated identity provider failure')); - } - return Promise.resolve(); + signinRedirectCalled = true; + return identityProviderUnreachable ? Promise.reject(new Error('Simulated identity provider failure')) : Promise.resolve(); }; const authValue = { signinRedirect } as unknown as AuthContextProps; - const PageComponent = state.site === 'community' ? CommunitySectionLayout : StaffSectionLayout; + const PageComponent = 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 originalConsoleError = console.error; + let consoleErrorCalled = false; + console.error = () => { + consoleErrorCalled = true; }; - const rendered = mountComponent(wrapped, { wrapper: wrapOcomComponent() }); - this.setHeaderContainer(rendered.container); - try { - await ClickHeaderSignIn(rendered.container).performAs(actorCalled(state.actorName)); + await actor.attemptsTo(Render.component(wrapped, { wrapper: wrapOcomComponent() }), ClickHeaderSignIn()); } finally { - if (state.originalConsoleError) { - console.error = state.originalConsoleError; - } - const actor = actorCalled(state.actorName); + console.error = originalConsoleError; await actor.attemptsTo( - notes().set('signinRedirectCalled', state.signinRedirectCalled), - notes().set('consoleErrorCalled', state.errorCalled), - notes().set('fallbackInvoked', state.errorCalled), + notes().set('signinRedirectCalled', signinRedirectCalled), + notes().set('consoleErrorCalled', consoleErrorCalled), + notes().set('fallbackInvoked', consoleErrorCalled), ); } }); -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')); +Then('{word} is taken to the sign-in flow', async (actorName: string) => { + const called = await actorCalled(actorName).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')); +Then('{word} can still reach the sign-in page', async (actorName: string) => { + const fallback = await actorCalled(actorName).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/tasks/click-header-sign-in.ts b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts index dbf767f8a..66c4f4745 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/authentication/tasks/click-header-sign-in.ts @@ -1,23 +1,23 @@ -import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { TaskStep } from '@cellix/serenity-framework/serenity'; import { HomePage } from '@ocom-verification/verification-shared/pages'; -import { type Activity, Task } from '@serenity-js/core'; +import { type Actor, Task } from '@serenity-js/core'; import type { AcceptanceUiHomePage } from '../../../shared/page-contracts.ts'; -async function flushAsync(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); +/** Let the sign-in handler's async work settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); } -export const ClickHeaderSignIn = (container: HTMLElement) => +export const ClickHeaderSignIn = (): Task => Task.where( '#actor clicks the sign-in button on the home page', - new TaskStep('#actor clicks the sign-in button', async () => { - const adapter = new JsdomPageAdapter(container); - const page: AcceptanceUiHomePage = new HomePage(adapter); + new TaskStep('#actor clicks the sign-in button', async (actor) => { + const page: AcceptanceUiHomePage = new HomePage(new DomPageAdapter(RenderInDom.as(actor).container)); await page.clickSignIn(); - await flushAsync(); - }) as Activity, + + await flushPendingReactWork(); + }), ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts index c8f9fdf09..d3f7091cd 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts @@ -1,5 +1,4 @@ export interface CommunityUiNotes { communityName: string; formSubmitted: boolean; - lastValidationError: string; } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts deleted file mode 100644 index 61865a446..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-error-message.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { notes, Question } from '@serenity-js/core'; -import type { CommunityUiNotes } from '../notes/community-notes.ts'; - -export const CommunityErrorMessage = () => Question.about('the community form error message', (actor) => actor.answer(notes().get('lastValidationError'))); 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 index ee426c518..0d3394237 100644 --- 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 @@ -1,125 +1,67 @@ -import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; -import { mountComponent } from '@cellix/serenity-framework/jsdom/react-render'; -import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { CommunityPage } from '@ocom-verification/verification-shared/pages'; -import { actorCalled, notes } from '@serenity-js/core'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import { CommunityCreate } from '../../../../../../ocom/ui-community-route-accounts/src/components/community-create.tsx'; import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; -import type { CellixUiWorld } from '../../../world.ts'; import type { CommunityUiNotes } from '../notes/community-notes.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); +Given('{word} is an authenticated community owner', async (actorName: string) => { 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', '')); + await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? '')); }; - const rendered = mountComponent(, { wrapper: wrapOcomComponent() }); - this.setCommunityContainer(rendered.container); - - await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), notes().set('lastValidationError', '')); + await actor.attemptsTo(notes().set('formSubmitted', false), notes().set('communityName', ''), Render.component(, { wrapper: wrapOcomComponent() })); }); -When('{word} creates a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { - this.setCommunityActorName(actorName); - const actor = actorCalled(actorName); +When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); - - await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); + await actorCalled(actorName).attemptsTo(CreateCommunity(communityName)); }); -When('{word} attempts to create a community with:', async function (this: CellixUiWorld, actorName: string, dataTable: DataTable) { - this.setCommunityActorName(actorName); - const actor = actorCalled(actorName); +When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { const { name: communityName = '' } = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); - - await actor.attemptsTo(CreateCommunity(this.getCommunityContainer(), communityName)); + await actorCalled(actorName).attemptsTo(CreateCommunity(communityName)); }); -Then('the community should be created successfully', async function (this: CellixUiWorld) { - const actor = actorCalled(this.getCommunityActorName()); - const submitted = await actor.answer(CommunityCreatedFlag()); - +Then('the community should be created successfully', async () => { + const submitted = await actorInTheSpotlight().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()); - +Then('the community name should be {string}', async (expectedName: string) => { + const name = await actorInTheSpotlight().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 = ActorName.resolve(actorName, { defaultName: this.getCommunityActorName() }); +Then('{word} should see a community error for {string}', async (_actorName: string, fieldName: string) => { + const actor = actorInTheSpotlight(); + const page: AcceptanceUiCommunityPage = new CommunityPage(new DomPageAdapter(RenderInDom.as(actor).container)); + const errorText = (await page.firstValidationError.textContent()) ?? ''; - const container = this.getCommunityContainer(); - const adapter = new JsdomPageAdapter(container); - const page = new CommunityPage(adapter) as AcceptanceUiCommunityPage; + const isFieldMentioned = errorText.toLowerCase().includes(fieldName.toLowerCase()); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|please input/i.test(errorText); - 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 (!errorText || (!isFieldMentioned && !isValidationPattern)) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${errorText}"`); } - - 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.'); - } +Then('no community should be created', async () => { + const submitted = await actorInTheSpotlight().answer(CommunityCreatedFlag()); + if (submitted) { + throw new Error('Expected no community to be created, but the form was submitted'); } }); 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 305801dc3..39168253b 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,28 +1,25 @@ -import { JsdomPageAdapter } from '@cellix/serenity-framework/pages/jsdom'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { TaskStep } from '@cellix/serenity-framework/serenity'; import { CommunityPage } from '@ocom-verification/verification-shared/pages'; -import { type Activity, Task } from '@serenity-js/core'; +import { type Actor, Task } from '@serenity-js/core'; import type { AcceptanceUiCommunityPage } from '../../../shared/page-contracts.ts'; -async function flushAsync(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); +/** Let the form's async `onFinish`/`onSave` handlers settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); } -export const CreateCommunity = (container: HTMLElement, name: string) => +export const CreateCommunity = (name: string): Task => Task.where( - `#actor fills community name "${name}" and submits`, - new TaskStep(`#actor submits community name "${name}"`, async () => { - const adapter = new JsdomPageAdapter(container); - const page: AcceptanceUiCommunityPage = new CommunityPage(adapter); + `#actor creates a community named "${name}"`, + new TaskStep(`#actor fills the community name "${name}" and submits`, async (actor) => { + const page: AcceptanceUiCommunityPage = new CommunityPage(new DomPageAdapter(RenderInDom.as(actor).container)); await page.fillName(name); await page.clickCreate(); - await flushAsync(); - }) as Activity, + await flushPendingReactWork(); + }), ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts index 0c531114f..d45035281 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/cucumber-lifecycle-hooks.ts @@ -1,7 +1,8 @@ import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; -import { unmountComponent } from '@cellix/serenity-framework/jsdom/react-render'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; import { getTimeout } from '@cellix/serenity-framework/settings'; import { After } from '@cucumber/cucumber'; +import { actorInTheSpotlight } from '@serenity-js/core'; import type { CellixUiWorld } from '../world.ts'; registerWorldLifecycleHooks({ @@ -13,5 +14,9 @@ registerWorldLifecycleHooks({ }); After({ timeout: getTimeout('uiCleanup') }, () => { - unmountComponent(); + try { + RenderInDom.as(actorInTheSpotlight()).unmount(); + } catch { + /* No component was rendered in this scenario. */ + } }); 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 cc39388de..1b7c8d7b3 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -3,7 +3,6 @@ * Cucumber imports this file, which then loads all context-specific step definitions. */ -import '@cellix/serenity-framework/jsdom/setup'; import '../shared/cucumber-lifecycle-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 986ea574d..747d7249f 100644 --- a/packages/ocom-verification/acceptance-ui/src/world.ts +++ b/packages/ocom-verification/acceptance-ui/src/world.ts @@ -1,53 +1,17 @@ -import { ManagedSerenityWorld, type ManagedSerenityWorldOptions } from '@cellix/serenity-framework/cucumber'; +import { registerManagedSerenityWorld } from '@cellix/serenity-framework/cucumber'; +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; import { SerenityCast } from '@cellix/serenity-framework/serenity'; -import { type IWorldOptions, setWorldConstructor } from '@cucumber/cucumber'; -const uiWorldConfig: ManagedSerenityWorldOptions> = { +export const CellixUiWorld = registerManagedSerenityWorld>({ infrastructure: { ensureStarted: () => Promise.resolve(), getState: () => ({}), }, - createCast: () => new SerenityCast({ useNotepad: true }), -}; - -export class CellixUiWorld extends ManagedSerenityWorld> { - private communityContainer: HTMLElement | null = null; - private communityActorName = ''; - private headerContainer: HTMLElement | null = null; - - constructor(options: IWorldOptions) { - super(options, uiWorldConfig); - } - - 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); + createCast: () => + new SerenityCast({ + useNotepad: true, + abilities: [() => new RenderInDom()], + }), +}); + +export type CellixUiWorld = InstanceType; diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index a1ea4a843..5966e2dba 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -13,5 +13,5 @@ "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/jsdom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts index d03d4f5b0..7f3035b8b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/authentication/step-definitions/header-login.steps.ts @@ -65,7 +65,7 @@ When('{word} chooses to sign in', async function (this: HeaderE2EWorld, actorNam 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'); + const baseUrl = s.site === 'community' ? (infra.getUiBaseUrl('community') ?? 'https://ownercommunity.localhost:1355') : (infra.getUiBaseUrl('staff') ?? '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. diff --git a/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts index 318008c19..2fbdc6d91 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/shared-infrastructure.ts @@ -32,18 +32,12 @@ const infrastructure = E2EInfrastructure.using({ .addUiPortal('community', createCommunityUiPortalServer()) .addUiPortal('staff', createStaffUiPortalServer()); -interface InfrastructureState extends E2EInfrastructureState { - staffBaseUrl: string | undefined; - communityBaseUrl: string | undefined; +export function getState(): E2EInfrastructureState { + return infrastructure.getState(); } -export function getState(): InfrastructureState { - const state = infrastructure.getState(); - return { - ...state, - communityBaseUrl: state.uiPortalBaseUrls['community'], - staffBaseUrl: state.uiPortalBaseUrls['staff'], - }; +export function getUiBaseUrl(site: 'community' | 'staff'): string | undefined { + return infrastructure.getState().uiPortalBaseUrls[site]; } export async function resetScenarioState(): Promise { diff --git a/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts b/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts index 3c51cb5c0..d8acc69ab 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/test-server-factories.ts @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { ApiTestServer, AuthTestServer, AzuriteTestServer, UiPortalTestServer } from '@cellix/serenity-framework/servers'; +import { ProcessTestServer } from '@cellix/serenity-framework/servers'; import { appPaths } from './environment/app-paths.ts'; import { e2eEnv, getPortlessDevScript } from './environment/dev-script.ts'; import { buildUrl, cleanupTestEnvironment, getHostnames, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer } from './environment/test-environment.ts'; @@ -9,8 +9,8 @@ const hostnames = getHostnames(); export { buildUrl, cleanupTestEnvironment, initTestEnvironment, mockOidcAudience, mockOidcEndpoint, mockOidcIssuer, mockStaffOidcIssuer }; -export function createTestApiServer(getMongoConnectionString: () => string): ApiTestServer { - return new ApiTestServer({ +export function createTestApiServer(getMongoConnectionString: () => string): ProcessTestServer { + return new ProcessTestServer({ cwd: appPaths.apiDir, executable: 'pnpm', extraEnv: () => @@ -44,8 +44,8 @@ export function createTestApiServer(getMongoConnectionString: () => string): Api }); } -export function createTestAzuriteServer(): AzuriteTestServer { - return new AzuriteTestServer({ +export function createTestAzuriteServer(): ProcessTestServer { + return new ProcessTestServer({ cwd: appPaths.apiDir, executable: 'node', extraEnv: () => { @@ -63,8 +63,8 @@ export function createTestAzuriteServer(): AzuriteTestServer { }); } -export function createTestOAuth2Server(): AuthTestServer { - return new AuthTestServer({ +export function createTestOAuth2Server(): ProcessTestServer { + return new ProcessTestServer({ cwd: appPaths.oauth2MockDir, executable: 'pnpm', getUrl: () => mockOidcIssuer, @@ -77,8 +77,8 @@ export function createTestOAuth2Server(): AuthTestServer { }); } -export function createCommunityUiPortalServer(): UiPortalTestServer { - return new UiPortalTestServer({ +export function createCommunityUiPortalServer(): ProcessTestServer { + return new ProcessTestServer({ cwd: appPaths.uiCommunityDir, executable: 'pnpm', extraEnv: () => ({ @@ -92,8 +92,8 @@ export function createCommunityUiPortalServer(): UiPortalTestServer { }); } -export function createStaffUiPortalServer(): UiPortalTestServer { - return new UiPortalTestServer({ +export function createStaffUiPortalServer(): ProcessTestServer { + return new ProcessTestServer({ cwd: appPaths.uiStaffDir, executable: 'pnpm', extraEnv: () => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faafb27b6..0d6ffe217 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,7 +237,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) apps/api: dependencies: @@ -316,7 +316,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) apps/docs: dependencies: @@ -386,7 +386,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) apps/server-mongodb-memory-mock: dependencies: @@ -439,7 +439,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) apps/ui-community: dependencies: @@ -560,7 +560,7 @@ importers: version: 0.28.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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) apps/ui-staff: dependencies: @@ -660,7 +660,7 @@ importers: version: 0.28.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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/api-services-spec: devDependencies: @@ -696,7 +696,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) packages/cellix/config-rolldown: devDependencies: @@ -720,7 +720,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/config-typescript: {} @@ -740,7 +740,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/domain-seedwork: devDependencies: @@ -761,7 +761,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/event-bus-seedwork-node: dependencies: @@ -829,7 +829,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/graphql-core: dependencies: @@ -857,7 +857,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/mongoose-seedwork: dependencies: @@ -894,7 +894,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/serenity-framework: dependencies: @@ -910,6 +910,9 @@ importers: '@cucumber/messages': specifier: 'catalog:' version: 32.3.1 + '@happy-dom/global-registrator': + specifier: ^20.9.0 + version: 20.9.0 '@serenity-js/core': specifier: 'catalog:' version: 3.42.2 @@ -919,9 +922,9 @@ importers: graphql-depth-limit: specifier: ^1.1.0 version: 1.1.0(graphql@16.12.0) - jsdom: - specifier: 'catalog:' - version: 26.1.0 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 mongodb: specifier: 'catalog:' version: 6.18.0 @@ -961,7 +964,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) packages/cellix/server-mongodb-memory-mock-seedwork: dependencies: @@ -1014,7 +1017,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/cellix/ui-core: dependencies: @@ -1093,7 +1096,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom-verification/acceptance-api: dependencies: @@ -1269,7 +1272,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) packages/ocom-verification/e2e-tests: dependencies: @@ -1377,7 +1380,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/context-spec: dependencies: @@ -1430,7 +1433,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/domain: dependencies: @@ -1491,7 +1494,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/event-handler: dependencies: @@ -1553,7 +1556,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/graphql-handler: dependencies: @@ -1596,7 +1599,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/persistence: dependencies: @@ -1645,7 +1648,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/rest: dependencies: @@ -1707,7 +1710,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/service-blob-storage: dependencies: @@ -1815,7 +1818,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/service-token-validation: dependencies: @@ -1843,7 +1846,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-community-route-accounts: dependencies: @@ -1937,7 +1940,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-community-route-admin: dependencies: @@ -2031,7 +2034,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-community-route-root: dependencies: @@ -2101,7 +2104,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-community-shared: dependencies: @@ -2180,7 +2183,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-shared: dependencies: @@ -2277,7 +2280,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-route-community-management: dependencies: @@ -2320,7 +2323,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-route-finance: dependencies: @@ -2363,7 +2366,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-route-root: dependencies: @@ -2406,7 +2409,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-route-tech-admin: dependencies: @@ -2449,7 +2452,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-route-user-management: dependencies: @@ -2492,7 +2495,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages/ocom/ui-staff-shared: dependencies: @@ -2541,7 +2544,7 @@ importers: version: 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: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) packages: @@ -4859,6 +4862,10 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@happy-dom/global-registrator@20.9.0': + resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==} + engines: {node: '>=20.0.0'} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -6744,6 +6751,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -8409,6 +8419,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -9020,6 +9034,10 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-ansi@4.0.1: resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==} engines: {node: '>=8'} @@ -13322,6 +13340,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -13664,7 +13686,7 @@ snapshots: minimist: 1.2.8 parsecurrency: 1.1.1 ts-morph: 27.0.2 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) '@ant-design/cli@6.3.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': dependencies: @@ -17012,6 +17034,14 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@happy-dom/global-registrator@20.9.0': + dependencies: + '@types/node': 24.10.1 + happy-dom: 20.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 @@ -18525,7 +18555,7 @@ snapshots: '@vitest/browser': 4.1.6(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.6) '@vitest/browser-playwright': 4.1.6(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.6) '@vitest/runner': 4.1.6 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) transitivePeerDependencies: - react - react-dom @@ -18541,7 +18571,7 @@ snapshots: '@vitest/browser': 4.1.6(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.6) '@vitest/browser-playwright': 4.1.6(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.6) '@vitest/runner': 4.1.6 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) transitivePeerDependencies: - react - react-dom @@ -19012,6 +19042,8 @@ snapshots: '@types/webidl-conversions@7.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -19086,7 +19118,7 @@ snapshots: '@vitest/mocker': 4.1.6(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)) playwright: 1.59.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) transitivePeerDependencies: - bufferutil - msw @@ -19100,7 +19132,7 @@ snapshots: '@vitest/mocker': 4.1.6(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)) playwright: 1.59.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) transitivePeerDependencies: - bufferutil - msw @@ -19116,7 +19148,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -19134,7 +19166,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -19154,7 +19186,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)) transitivePeerDependencies: - supports-color @@ -20947,6 +20979,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + errno@0.1.8: dependencies: prr: 1.0.1 @@ -21762,6 +21796,18 @@ snapshots: handle-thing@2.0.1: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 24.10.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-ansi@4.0.1: dependencies: ansi-regex: 4.1.1 @@ -26559,7 +26605,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(jsdom@26.1.0)(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@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(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)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(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)) @@ -26586,11 +26632,12 @@ snapshots: '@types/node': 22.19.15 '@vitest/browser-playwright': 4.1.6(playwright@1.59.0)(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@4.1.6) '@vitest/coverage-istanbul': 4.1.6(vitest@4.1.6) + happy-dom: 20.9.0 jsdom: 26.1.0 transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(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)): + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(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.6 '@vitest/mocker': 4.1.6(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)) @@ -26617,6 +26664,7 @@ snapshots: '@types/node': 24.10.1 '@vitest/browser-playwright': 4.1.6(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.6) '@vitest/coverage-istanbul': 4.1.6(vitest@4.1.6) + happy-dom: 20.9.0 jsdom: 26.1.0 transitivePeerDependencies: - msw @@ -26793,6 +26841,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: From 08306d4a23999dc620299471d9ff9e4495a9d0a7 Mon Sep 17 00:00:00 2001 From: rohit-r-kumar Date: Wed, 3 Jun 2026 18:49:00 +0530 Subject: [PATCH 4/7] Add Entra app-role route authorization for initial ui-staff sections (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create @apps/ui-staff bootstrap app and compose initial staff route packages Fixes #212 * fix build issue * feat: add ui-staff route packages with basic structure and vitest configuration * feat(ui-staff): update routing and authentication structure * add pnpm-lock.yaml * feat: add uuid dependency to package.json and update pnpm-lock.yaml * feat: add AuthLanding component for navigation and Apollo client setup * fix: update ApolloConnectionProps interface * Update Snyk ignore rules for UUID and adjust package versions for compatibility * update snyke ignore * update layout and styling in ThemeProvider component * fix: update dev script to use correct localhost domain * feat: integrate StaffAuthProvider for user identity management * update OIDC configuration and add mock data for staff authentication * feat: implement login page and update routing for authentication * fix build issue * chore: update TypeScript version to 6.0.3 in pnpm-lock.yaml * refine login page and add logout functionality * remove dist files * refactor: restructure authentication flow and remove login page component * remove .env from .gitignore * refactor: update build scripts to use tsgo and improve code formatting * refactor: update package.json scripts and remove unused dependencies * refactor: update entry points and project patterns in knip.json and package.json * refactor: remove unused dependencies from pnpm-lock.yaml * fix: correct token property name in ThemeProvider and adjust children rendering * fix: correct typo in OIDCConfig type and instance from 'noonce' to 'nonce' * refactor: update environment variable references and improve type definitions * format * feat: integrate react-oidc-context for authentication in header components * Changes before error encountered Agent-Logs-Url: https://github.com/CellixJs/cellixjs/sessions/b332a369-259e-49fd-b45e-84b75c4b82b5 Co-authored-by: ttrang-nguyen <126544378+ttrang-nguyen@users.noreply.github.com> * test: add unit tests for resolver-builder module and mergeResolvers functionality * Renaming: ui-community-route-shared -> ui-community-shared ui-staff-route-shared -> ui-staff-shared ui-components -> ui-shared * fix: rename ui-community-route-shared to ui-community-shared in knip.json * fix: correct Biome formatting in resolver-builder.test.ts Agent-Logs-Url: https://github.com/CellixJs/cellixjs/sessions/3d00d38e-39b6-480b-8f35-9451c37a4c08 Co-authored-by: rohit-r-kumar <175348946+rohit-r-kumar@users.noreply.github.com> * feat(ui-staff): add Entra app-role route authorization (#214) - Add StaffAppRoles constants (Staff.TechAdmin, Staff.Finance, Staff.ServiceLineOwner, Staff.CaseManager) - Add centralized staffRouteRoles mapping route paths to required roles - Extract extractRoles helper to staff-app-roles.ts (shared utility) - Add RequireRole component that reads StaffAuthContext and redirects to /unauthorized when user lacks the required role - Wrap each /staff/* route in App.tsx with RequireRole using the centralized mapping - Filter nav links in StaffRouteShell to only show sections the user can access based on their roles - Add react-router-dom and react-dom to ui-staff-shared dependencies - Add unit tests for StaffAppRoles, staffRouteRoles, extractRoles, and RequireRole component Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(ui-staff): add route-authorization scenario tests for all 4 Entra app roles (#214) Tests cover every role × route combination: - Staff.TechAdmin: /staff/tech allowed, all others blocked - Staff.Finance: /staff/finance allowed, all others blocked - Staff.CaseManager: /staff/community + /staff/users allowed, others blocked - Staff.ServiceLineOwner: /staff/community + /staff/users allowed, others blocked - No roles: all /staff/* routes blocked Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(ui-staff-routes): remove unused deps flagged by knip Remove antd and react-dom from dependencies where unused, and storybook from devDependencies across all ui-staff-route-* packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert package.json * test(ui-staff): add Playwright e2e tests for role-based route authorization Covers all 4 Entra app-role × 4 route combinations (20 access tests) plus 3 nav-link visibility tests = 23 tests total. Strategy: inject a fake OIDC session into sessionStorage via page.addInitScript() before the app loads. oidc-client-ts restores stored users without re-validating the JWT signature, so each test can control the user's roles independently without restarting the mock auth server. Test matrix: - Staff.TechAdmin: /tech ✓, /finance ✗, /community ✗, /users ✗ - Staff.Finance: /finance ✓, /tech ✗, /community ✗, /users ✗ - Staff.CaseManager: /community ✓, /users ✓, /finance ✗, /tech ✗ - Staff.ServiceLineOwner:/community ✓, /users ✓, /finance ✗, /tech ✗ - No roles: all 4 routes ✗ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove react-dom version from pnpm-lock.yaml * Refactor code structure for improved readability and maintainability * feat: implement theme storage functions and update theme context to use them * fix: refine theme context type and improve theme storage handling * fix: enhance theme context type and streamline theme storage handling * fix: correct import order for theme storage functions in theme context * feat: implement staff RBAC with backend user/role creation and permission-based routing - Add StaffRoleSectionPermissions domain value object with 4 permission flags: canManageCommunities, canManageUser, canManageFinance, canManageTechAdmin - Wire sectionPermissions into StaffRolePermissions domain model and persistence adapter - Add Mongoose schema subdocument for StaffRoleSectionPermissions - Create idempotent createDefaultRoles app service (4 roles mapped to AAD app roles) - Create createIfNotExists and queryByExternalId StaffUser app services - Add StaffUser readonly read-repository with role population - Wire StaffPortal passport lookup via StaffUserReadRepo.getByExternalId - Add currentStaffUserAndCreateIfNotExists GraphQL query with full type definitions - Refactor RequireRole to check backend permissions (permKey) with JWT role fallback - Refactor StaffRouteShell nav filtering to use permKey for permission-aware links - Add useStaffPermissions Apollo hook; refactor App.tsx to build StaffAuth from backend - Add BDD tests for StaffRoleSectionPermissions (12 scenarios, all permission flags) - Add sectionPermissions scenario to staff-role-permissions BDD test suite - Fix ReadonlyDataSource index test to include StaffUser mock model and assertions - Exclude e2e directory from Vitest coverage to fix pre-existing Playwright conflict - Mock useStaffPermissions in App.test.tsx so unit tests use JWT role fallback path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add staff user query and permissions handling in community and admin sections * feat: add staff user role csv file * update staff role permissions and community list story * refactor: update community permissions structure and related queries - Changed to in GraphQL queries and components. - Updated permission checks in and to reflect new permission structure. - Renamed to in admin permissions. - Introduced new finance and tech admin permission features with corresponding tests. - Added user permissions management with appropriate validation and tests. - Created Storybook stories for various permission scenarios in . * refactor: improve staff role permissions test structure and enhance type references * refactor: remove default roleType from StaffRoleSchema * add mongoose vulnerability exception with detailed reasoning * feat: Add staff user resolvers and tests for querying and creating staff users - Implemented GraphQL resolvers for querying and creating staff user entities. - Added feature tests for staff user resolvers, covering scenarios for JWT verification and AAD roles. - Created a read repository for staff users with tests for model presence and document retrieval. - Enhanced staff role domain adapter with additional permission scenarios and tests. * refactor: Simplify function definitions and improve readability in tests * Refactor code structure for improved readability and maintainability * refactor: Remove unused imports from theme context * feat: Update staff route management and authorization roles for community and user management * feat: Implement role-based access control for staff routes and permissions * feat: Implement role-based access control for staff routes and permissions * refactor: Rename VITE_FUNCTION_ENDPOINT to VITE_COMMON_API_ENDPOINT across multiple files * refactor: Rename VITE_FUNCTION_ENDPOINT to VITE_COMMON_API_ENDPOINT across multiple files * feat: Update dependencies for improved security and functionality * feat: Enhance role-based access control in staff routes and permissions * Removed staff-user-role csv file, refactored createDefaultRoles, updatedAuthLnading component * fix snyk issue * e2e and acceptance-ui tests for staff * update tests * chore: update protobufjs to resolved snyk issue * refactor: replace withScopedTransaction with withTransaction in staff role and user creation logic * feat: implement StaffUserUserPassport and update StaffUserPassport to utilize it * Added TechAdmin permission to finance, community-management, and user-management * downgrade packageManager to pnpm@10.30.1, add canManage permissions set to true on the default tech admin role, rename RoleName with default * chore: update protobufjs to version 7.5.8 * feat: enhance staff role management by adding default role instances and refactoring role creation logic * add query for fetching displayName * remove staff queries from ui-community route * update role names nad remove unused staff section permissions * feat(staff-role): add support for retrieving default roles by enterprise app role * Revert "Merge branch 'rohit-r-kumar/issue214' of github.com:CellixJs/cellixjs into rohit-r-kumar/issue214" This reverts commit 1583df2377812bf000a6f21cdfa315537eb14651, reversing changes made to 5d0a3d16cf97b72b1b2c40f2560a897f18d9a8c7. * Fix knip issues * refactor: update staff role handling and remove unused queries in community list * remove uuid and add enterpriseAppRole for finance and techAdmin * feat: add enterpriseAppRole handling in staff role tests and repository * Resolve test coverage failure and add getDefaultRoleByEnterpriseAppRole * chore: update dependencies for snyk issue * remove staffPermission from index.tsx * fix: correct script name from clear to clean in package.json * fix: update environment variable names for community and staff portals * feat: add staff context step definitions * add more test cases * add test cases * feat: implement staff user management features and update community management tests * upgrade axios version * feat: add Storybook stories for RequireRole and SectionLayoutContainer components, update package dependencies and remove duplicate dependency * upgrade vitest browser version to 4.1.6 * upgrade vitest browser playright to 4.1.6 * update vitest * fix: update @vitest/browser dependency to use catalog version * chore: add '@vitest/browser-playwright' version 4.1.6 to pnpm workspace catalog --------- Co-authored-by: Rohit Kumar Co-authored-by: Trang Nguyen Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ttrang-nguyen <126544378+ttrang-nguyen@users.noreply.github.com> Co-authored-by: rohit-r-kumar <175348946+rohit-r-kumar@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/docs/package.json | 2 +- .../src/contexts/theme-context.tsx | 17 +- apps/ui-staff/.env | 1 + apps/ui-staff/src/App.tsx | 130 +++-- .../ui/molecules/auth-landing/index.tsx | 39 +- apps/ui-staff/src/contexts/theme-context.tsx | 3 +- .../src/hooks/use-staff-permissions.ts | 101 ++++ apps/ui-staff/vitest.config.ts | 1 + codegen.yml | 15 + packages/cellix/config-vitest/package.json | 2 +- packages/cellix/ui-core/package.json | 4 +- packages/cellix/ui-core/src/index.ts | 1 + packages/cellix/ui-core/src/theme-storage.ts | 20 + .../contexts/staff/step-definitions/index.ts | 1 + .../step-definitions/staff-landing.steps.ts | 62 +++ .../src/step-definitions/index.ts | 1 + .../contexts/staff/abilities/staff-types.ts | 3 + .../staff/questions/staff-target-route.ts | 4 + .../create-staff-landing.steps.ts | 70 +++ .../contexts/staff/step-definitions/index.ts | 2 + .../staff/tasks/open-staff-landing.ts | 7 + .../src/step-definitions/index.ts | 1 + .../contexts/staff/abilities/staff-types.ts | 3 + .../staff/questions/staff-current-path.ts | 4 + .../contexts/staff/step-definitions/index.ts | 1 + .../step-definitions/staff-landing.steps.ts | 70 +++ .../staff/tasks/open-staff-landing.ts | 8 + .../src/shared/support/oauth2-login.ts | 5 +- .../servers/test-community-vite-server.ts | 2 +- .../support/servers/test-staff-vite-server.ts | 2 +- .../e2e-tests/src/step-definitions/index.ts | 1 + .../src/scenarios/staff/staff-landing.feature | 45 ++ .../verification-shared/src/settings/index.ts | 2 +- .../src/settings/local-settings.ts | 21 +- .../src/test-data/test-actors.ts | 9 + .../src/contexts/user/index.ts | 3 + .../staff-role/create-default-roles.test.ts | 493 ++++++++++++++++++ .../user/staff-role/create-default-roles.ts | 47 ++ .../features/create-default-roles.feature | 61 +++ .../src/contexts/user/staff-role/index.ts | 3 + .../staff-user/create-if-not-exists.test.ts | 431 +++++++++++++++ .../user/staff-user/create-if-not-exists.ts | 66 +++ .../features/create-if-not-exists.feature | 59 +++ .../features/query-by-external-id.feature | 11 + .../src/contexts/user/staff-user/index.ts | 16 + .../staff-user/query-by-external-id.test.ts | 82 +++ .../user/staff-user/query-by-external-id.ts | 12 + .../ocom/application-services/src/index.ts | 3 +- .../src/models/role/staff-role.model.ts | 65 ++- .../staff-role-community-permissions.feature | 16 +- .../staff-role-finance-permissions.feature | 50 ++ .../features/staff-role-permissions.feature | 17 +- .../staff-role-tech-admin-permissions.feature | 68 +++ .../staff-role-user-permissions.feature | 20 + .../staff-role/features/staff-role.feature | 19 + .../domain/contexts/user/staff-role/index.ts | 13 + .../staff-role-community-permissions.test.ts | 45 ++ .../staff-role-community-permissions.ts | 8 + .../staff-role/staff-role-defaults.test.ts | 116 +++++ .../staff-role-finance-permissions.test.ts | 180 +++++++ .../staff-role-finance-permissions.ts | 61 +++ .../staff-role/staff-role-permissions.test.ts | 45 ++ .../user/staff-role/staff-role-permissions.ts | 27 +- .../staff-role-tech-admin-permissions.test.ts | 239 +++++++++ .../staff-role-tech-admin-permissions.ts | 70 +++ .../staff-role-user-permissions.test.ts | 87 ++++ .../staff-role/staff-role-user-permissions.ts | 34 ++ .../user/staff-role/staff-role.repository.ts | 5 + .../user/staff-role/staff-role.test.ts | 106 +++- .../contexts/user/staff-role/staff-role.ts | 79 +++ .../staff-role/staff-role.value-objects.ts | 11 +- .../contexts/staff-user.user.passport.ts | 42 ++ .../features/staff-user.passport.feature | 2 +- .../staff-user/staff-user.passport.test.ts | 9 +- .../user/staff-user/staff-user.passport.ts | 10 +- .../features/staff-user-management.feature | 30 ++ .../interactions/create-community.ts | 2 +- .../community-management.steps.ts | 6 +- .../member-management.steps.ts | 44 ++ .../staff-user-management.steps.ts | 111 ++++ .../acceptance/support/member-test-utils.ts | 117 +++++ .../support/staff-user-test-utils.ts | 118 +++++ .../community-management.integration.test.ts | 2 +- .../src/schema/builder/resolver-builder.ts | 3 +- .../features/staff-user.resolvers.feature | 22 + .../src/schema/types/staff-user.graphql | 66 +++ .../schema/types/staff-user.resolvers.test.ts | 180 +++++++ .../src/schema/types/staff-user.resolvers.ts | 24 + .../member-invitation.domain-adapter.test.ts | 1 - .../staff-role.domain-adapter.feature | 167 +++++- .../features/staff-role.repository.feature | 10 + .../staff-role.domain-adapter.test.ts | 491 +++++++++++++++++ .../staff-role/staff-role.domain-adapter.ts | 177 ++++++- .../staff-role/staff-role.repository.test.ts | 42 +- .../user/staff-role/staff-role.repository.ts | 28 + .../src/datasources/readonly/index.test.ts | 16 +- .../src/datasources/readonly/index.ts | 4 + .../src/datasources/readonly/user/index.ts | 2 + .../staff-user.read-repository.feature | 23 + .../readonly/user/staff-user/index.ts | 11 + .../staff-user.read-repository.test.ts | 141 +++++ .../staff-user/staff-user.read-repository.ts | 35 ++ .../src/index.test.ts | 2 +- .../service-token-validation/src/index.ts | 26 +- .../src/components/community-list.stories.tsx | 1 + .../src/components/community-list.tsx | 13 +- .../ui-community-route-admin/src/index.tsx | 18 +- .../src/section-layout.container.tsx | 6 +- .../src/section-layout.graphql | 1 + .../src/section-layout.stories.tsx | 163 ++++++ .../src/section-layout.tsx | 9 +- packages/ocom/ui-shared/package.json | 2 +- .../src/index.tsx | 4 +- .../ocom/ui-staff-route-finance/src/index.tsx | 4 +- .../package.json | 1 + .../src/index.tsx | 4 +- packages/ocom/ui-staff-shared/package.json | 7 +- packages/ocom/ui-staff-shared/src/index.tsx | 30 +- .../src/require-role.stories.tsx | 158 ++++++ .../ocom/ui-staff-shared/src/require-role.tsx | 81 +++ .../src/section-layout-header.graphql | 13 + .../src/section-layout.container.stories.tsx | 123 +++++ .../src/section-layout.container.tsx | 50 ++ .../src/section-layout.stories.tsx | 190 ++++++- .../ui-staff-shared/src/section-layout.tsx | 81 +-- .../src/staff-app-roles.test.ts | 69 +++ .../ui-staff-shared/src/staff-app-roles.ts | 48 ++ .../ui-staff-shared/src/staff-route-shell.tsx | 48 +- pnpm-lock.yaml | 397 +++++++------- pnpm-workspace.yaml | 8 +- 130 files changed, 6401 insertions(+), 420 deletions(-) create mode 100644 apps/ui-staff/src/hooks/use-staff-permissions.ts create mode 100644 packages/cellix/ui-core/src/theme-storage.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature create mode 100644 packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/index.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts create mode 100644 packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-defaults.test.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts create mode 100644 packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts create mode 100644 packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts create mode 100644 packages/ocom/domain/tests/acceptance/features/staff-user-management.feature create mode 100644 packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts create mode 100644 packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts create mode 100644 packages/ocom/domain/tests/acceptance/support/member-test-utils.ts create mode 100644 packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts create mode 100644 packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature create mode 100644 packages/ocom/graphql/src/schema/types/staff-user.graphql create mode 100644 packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts create mode 100644 packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts create mode 100644 packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature create mode 100644 packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts create mode 100644 packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts create mode 100644 packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts create mode 100644 packages/ocom/ui-community-route-admin/src/section-layout.stories.tsx create mode 100644 packages/ocom/ui-staff-shared/src/require-role.stories.tsx create mode 100644 packages/ocom/ui-staff-shared/src/require-role.tsx create mode 100644 packages/ocom/ui-staff-shared/src/section-layout-header.graphql create mode 100644 packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx create mode 100644 packages/ocom/ui-staff-shared/src/section-layout.container.tsx create mode 100644 packages/ocom/ui-staff-shared/src/staff-app-roles.test.ts create mode 100644 packages/ocom/ui-staff-shared/src/staff-app-roles.ts diff --git a/apps/docs/package.json b/apps/docs/package.json index 64b40e9f8..67ef0a3e5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,7 @@ "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", - "clear": "docusaurus clear", + "clean": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index 8a7a5a870..fa94b9e65 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { loadStoredTheme, saveStoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -15,7 +16,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: 'light' | 'dark' | 'custom'; } | undefined; setTheme: (tokens: Partial, types: string) => void; @@ -91,13 +92,15 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { type: 'custom', }; } - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + if (valueToSet) { + saveStoredTheme(valueToSet); + } return valueToSet; }); }, []); useEffect(() => { - const extractFromLocal = JSON.parse(localStorage.getItem('themeProp') || '{}'); + const extractFromLocal = loadStoredTheme(); if (extractFromLocal && extractFromLocal.type === 'dark') { setTheme( { @@ -119,22 +122,22 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { } else if (extractFromLocal && extractFromLocal.type === 'custom') { setTheme( { - colorTextBase: extractFromLocal.hardCodedTokens.textColor, - colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor, + colorTextBase: extractFromLocal.hardCodedTokens?.textColor, + colorBgBase: extractFromLocal.hardCodedTokens?.backgroundColor, }, 'custom', ); return; } else { const valueToSet = { - type: 'light', + type: 'light' as const, token: theme.defaultSeed, hardCodedTokens: { textColor: '#000000', backgroundColor: '#ffffff', }, }; - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + saveStoredTheme(valueToSet); setTheme(theme.defaultSeed, 'light'); return; } diff --git a/apps/ui-staff/.env b/apps/ui-staff/.env index b7fbe05a5..cd0300829 100644 --- a/apps/ui-staff/.env +++ b/apps/ui-staff/.env @@ -3,3 +3,4 @@ VITE_APP_UI_STAFF_AAD_CLIENTID=mock-client VITE_APP_UI_STAFF_AAD_REDIRECT_URI=https://staff.ownercommunity.localhost:1355/auth-redirect VITE_APP_UI_STAFF_AAD_SCOPES=openid VITE_COMMON_API_ENDPOINT=https://data-access.ownercommunity.localhost:1355/api/graphql +VITE_APP_UI_STAFF_BASE_URL=https://staff.ownercommunity.localhost:1355 diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index f7171e4a4..e26ad3e83 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -5,23 +5,89 @@ import { Root as Finance } from '@ocom/ui-staff-route-finance'; import { Root } from '@ocom/ui-staff-route-root'; import { Root as TechAdmin } from '@ocom/ui-staff-route-tech-admin'; import { Root as UserManagement } from '@ocom/ui-staff-route-user-management'; -import { StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { StaffAuthContext, StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { Spin } from 'antd'; +import { useContext } from 'react'; import { useAuth } from 'react-oidc-context'; -import { Outlet, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import './App.css'; import { AuthLanding } from './components/ui/molecules/auth-landing/index.tsx'; import { client } from './components/ui/organisms/apollo-connection/apollo-client-links.tsx'; import { ApolloConnection } from './components/ui/organisms/apollo-connection/index.tsx'; +import { useStaffPermissions } from './hooks/use-staff-permissions.ts'; import { Unauthorized } from './unauthorized.tsx'; +function StaffRoutes() { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + + let defaultStaffRoute = '/unauthorized'; + if (canManageTechAdmin) { + defaultStaffRoute = '/staff/tech'; + } else if (canManageFinance) { + defaultStaffRoute = '/staff/finance'; + } else if (canManageCommunities) { + defaultStaffRoute = '/staff/community-management'; + } else if (canManageUsers) { + defaultStaffRoute = '/staff/user-management'; + } + + return ( + + + } + /> + {canManageCommunities && ( + } + /> + )} + {canManageUsers && ( + } + /> + )} + {canManageFinance && ( + } + /> + )} + {canManageTechAdmin && ( + } + /> + )} + + } + /> + + ); +} + export default function App() { const rootSection = ; const auth = useAuth(); - // Build a best-effort identity object to supply to shared placeholders - - // Provide a best-effort raw profile to the shared staff shell. StaffRouteShell will - // attempt to extract display name and roles from this raw profile. const identity = { raw: (auth?.user?.profile as Record) ?? undefined, onLogout: () => HandleLogout(auth, client, globalThis.location.origin), @@ -33,13 +99,9 @@ export default function App() { ); - // Staff section acts as the parent route element and must render an Outlet so - // nested child routes declared in the top-level Routes are rendered in place. const staffSectionElement = ( - - - + ); @@ -59,34 +121,32 @@ export default function App() { element={} /> - {/* Parent staff route: child routes must be declared as nested Route elements - so relative paths like "users/*" resolve against /staff. */} + {/* StaffSection renders StaffAuthProvider + StaffRoutes which handles all + authenticated sub-routes with permission guards. No nested Route children + are needed here because StaffRoutes defines its own Routes block. */} - } - /> - } - /> - } - /> - } - /> - } - /> - + /> ); } + +function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { + const { permissions, user, loading } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx index 251645e9b..41fc0aa53 100644 --- a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx @@ -1,5 +1,42 @@ +import { Spin } from 'antd'; import { Navigate } from 'react-router-dom'; +import { useStaffPermissions } from '../../../../hooks/use-staff-permissions.ts'; export const AuthLanding: React.FC = () => { - return ; + const { permissions, loading, error } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + let targetRoute = '/unauthorized'; + if (permissions?.canManageTechAdmin) { + targetRoute = '/staff/tech'; + } else if (permissions?.canManageFinance) { + targetRoute = '/staff/finance'; + } else if (permissions?.canManageCommunities) { + targetRoute = '/staff/community-management'; + } else if (permissions?.canManageUsers) { + targetRoute = '/staff/user-management'; + } + + return ( + + ); }; diff --git a/apps/ui-staff/src/contexts/theme-context.tsx b/apps/ui-staff/src/contexts/theme-context.tsx index 3bc79478b..b23f6c46e 100644 --- a/apps/ui-staff/src/contexts/theme-context.tsx +++ b/apps/ui-staff/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { type StoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -10,7 +11,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: StoredTheme['type']; } | undefined; setTheme: (tokens: Partial, type: string) => void; diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts new file mode 100644 index 000000000..0c5f4e3d6 --- /dev/null +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -0,0 +1,101 @@ +import { gql, useQuery } from '@apollo/client'; + +const CURRENT_STAFF_USER_QUERY = gql` + query CurrentStaffUserAndCreateIfNotExists { + currentStaffUserAndCreateIfNotExists { + id + externalId + firstName + lastName + email + displayName + role { + id + roleName + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + +interface StaffUserQueryResult { + currentStaffUserAndCreateIfNotExists: { + id: string; + externalId: string; + firstName: string; + lastName: string; + email: string; + displayName: string; + role?: { + id: string; + roleName: string; + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const useStaffPermissions = (): { + permissions: StaffPermissions | undefined; + user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; + loading: boolean; + error: Error | undefined; +} => { + const { data, loading, error } = useQuery(CURRENT_STAFF_USER_QUERY, { + fetchPolicy: 'cache-first', + }); + + const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions; + const currentUser = data?.currentStaffUserAndCreateIfNotExists; + + // Treat a TechAdmin as an implicit manager of all sections + const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false; + + const permissions: StaffPermissions | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, + canManageTechAdmin: isTechAdmin, + } + : undefined; + + return { + permissions, + user: currentUser + ? { + id: currentUser.id, + displayName: currentUser.displayName, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + email: currentUser.email, + } + : undefined, + loading, + error, + }; +}; diff --git a/apps/ui-staff/vitest.config.ts b/apps/ui-staff/vitest.config.ts index 17bec4371..198b98ee6 100644 --- a/apps/ui-staff/vitest.config.ts +++ b/apps/ui-staff/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( test: { environment: 'jsdom', passWithNoTests: true, + exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], }, }), ); diff --git a/codegen.yml b/codegen.yml index cd441b5ae..95e9f2dc3 100644 --- a/codegen.yml +++ b/codegen.yml @@ -72,6 +72,7 @@ generates: Community: "import('@ocom/domain').Domain.Contexts.Community.Community.CommunityEntityReference" EndUser: "import('@ocom/domain').Domain.Contexts.User.EndUser.EndUserEntityReference" EndUserRole: "import('@ocom/domain').Domain.Contexts.Community.Role.EndUserRole.EndUserRoleEntityReference" + StaffUser: "import('@ocom/domain').Domain.Contexts.User.StaffUser.StaffUserEntityReference" plugins: - typescript - typescript-resolvers @@ -140,6 +141,20 @@ generates: - typescript-operations - typed-document-node + # UI staff shared components client types + './packages/ocom/ui-staff-shared/src/generated.tsx': + documents: './packages/ocom/ui-staff-shared/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/packages/cellix/config-vitest/package.json b/packages/cellix/config-vitest/package.json index bdc653832..5592556dc 100644 --- a/packages/cellix/config-vitest/package.json +++ b/packages/cellix/config-vitest/package.json @@ -16,7 +16,7 @@ "dependencies": { "@cellix/config-typescript": "workspace:*", "@storybook/addon-vitest": "^9.1.20", - "@vitest/browser-playwright": "^4.1.2", + "@vitest/browser-playwright": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/cellix/ui-core/package.json b/packages/cellix/ui-core/package.json index 9e70880a6..1f7df129b 100644 --- a/packages/cellix/ui-core/package.json +++ b/packages/cellix/ui-core/package.json @@ -45,8 +45,8 @@ "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.6", - "@vitest/browser": "^4.1.2", - "@vitest/browser-playwright": "^4.1.2", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", "@vitest/coverage-istanbul": "catalog:", "jsdom": "catalog:", "@testing-library/react": "^16.3.0", diff --git a/packages/cellix/ui-core/src/index.ts b/packages/cellix/ui-core/src/index.ts index 9edf82f72..227b5c2f4 100644 --- a/packages/cellix/ui-core/src/index.ts +++ b/packages/cellix/ui-core/src/index.ts @@ -1 +1,2 @@ export * from './components/index.ts'; +export * from './theme-storage.ts'; diff --git a/packages/cellix/ui-core/src/theme-storage.ts b/packages/cellix/ui-core/src/theme-storage.ts new file mode 100644 index 000000000..0ff4303e8 --- /dev/null +++ b/packages/cellix/ui-core/src/theme-storage.ts @@ -0,0 +1,20 @@ +export type StoredTheme = { + type?: 'light' | 'dark' | 'custom'; + hardCodedTokens?: { textColor?: string; backgroundColor?: string }; + token?: unknown; +}; + +const THEME_STORAGE_KEY = 'themeProp'; + +export function loadStoredTheme(): StoredTheme { + try { + return JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) ?? '{}') as StoredTheme; + } catch { + localStorage.removeItem(THEME_STORAGE_KEY); + return {}; + } +} + +export function saveStoredTheme(value: StoredTheme): void { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value)); +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..c05505539 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,62 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +interface StaffApiNotes { + targetRoute: string; +} + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + actorRoles.set(actorName, normalizeRole(roleName)); + await actorCalled(actorName).attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('targetRoute', defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('targetRoute', resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(notes().get('targetRoute')); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); 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 395b3f752..55194db74 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -5,3 +5,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; \ No newline at end of file diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..70791d089 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffUiNotes { + targetRoute: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts new file mode 100644 index 000000000..4687fc54c --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const StaffTargetRoute = () => Question.about('staff landing target route', (actor) => actor.answer(notes().get('targetRoute'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts new file mode 100644 index 000000000..f50c14577 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; +import { StaffTargetRoute } from '../questions/staff-target-route.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(StaffTargetRoute()); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..8b998c9ea --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff context step definitions +import './create-staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..cade8e92a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,7 @@ +import { type Actor, Interaction, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where('#actor opens the staff app landing', async (actor) => { + await (actor as Actor).attemptsTo(notes().set('targetRoute', targetRoute)); + }); 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 2107af436..d2f521531 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -6,4 +6,5 @@ import '../shared/support/ui/setup-jsdom.ts'; import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..e7dab9ca2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffE2ENotes { + currentPath: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts new file mode 100644 index 000000000..1ff8c76b0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const StaffCurrentPath = () => Question.about('current staff app path', (actor) => actor.answer(notes().get('currentPath'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..8ea22e414 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; +import { StaffCurrentPath } from '../questions/staff-current-path.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const currentPath = await actor.answer(StaffCurrentPath()); + + if (currentPath !== expectedRoute) { + throw new Error(`Expected path "${expectedRoute}", but got "${currentPath}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..d777aba21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where(the`#actor opens staff landing`, async (actor) => { + const fullActor = actor as unknown as Actor; + await fullActor.attemptsTo(notes().set('currentPath', targetRoute)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts index b7de3ea9a..a6d021c05 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 @@ -50,14 +50,15 @@ export async function performOAuth2Login(page: Page): Promise { * during server setup. This interaction navigates to a protected route and * verifies the page loads without being kicked to the auth provider. */ -export const OAuth2Login = (_email?: string, _password?: string) => +export const OAuth2Login = (_email?: string, _password?: string, options?: { path?: string; expectedHost?: string }) => Interaction.where(the`#actor logs in via OAuth2`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); + const targetPath = options?.path ?? '/community/accounts'; // Session tokens live in sessionStorage from pre-auth. try { - await page.goto('/community/accounts', { + await page.goto(targetPath, { waitUntil: 'networkidle', timeout: 30_000, }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts index 096d345da..917973f55 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-community-vite-server.ts @@ -31,7 +31,7 @@ export class TestCommunityViteServer extends PortlessServer { return { BROWSER: 'none', NODE_ENV: 'development', - VITE_BASE_URL: uiBase, + VITE_APP_UI_COMMUNITY_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, diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts index 897a3d3db..6f0b3e005 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -30,7 +30,7 @@ export class TestStaffViteServer extends PortlessServer { return { BROWSER: 'none', NODE_ENV: 'development', - VITE_BASE_URL: uiBase, + VITE_APP_UI_STAFF_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', 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 fb2ff8a4f..250ea78f1 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,4 +5,5 @@ import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature new file mode 100644 index 000000000..b38bf03fc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature @@ -0,0 +1,45 @@ +Feature: Staff workspace access + + As a staff business user + I want each workspace to follow role-based access rules + So that sensitive operations are only available to authorized roles + + Scenario: Finance staff user is directed to the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user is directed to the tech admin workspace + Given Alice is an authenticated "tech admin" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/tech" + + Scenario: Service line owner is directed to the community management workspace + Given Alice is an authenticated "service line owner" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Case manager is directed to the community management workspace + Given Alice is an authenticated "case manager" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Finance staff user can work in the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user can work in the finance workspace + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Service line owner cannot work in the finance workspace + Given Alice is an authenticated "service line owner" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" + + Scenario: Case manager cannot work in the finance workspace + Given Alice is an authenticated "case manager" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts index 4e28ad534..b1baf898c 100644 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ b/packages/ocom-verification/verification-shared/src/settings/index.ts @@ -1,4 +1,4 @@ -export { apiSettings, uiSettings } from './local-settings.ts'; +export { apiSettings, uiCommunitySettings, uiStaffSettings } from './local-settings.ts'; export { findWorkspaceRoot, readDotEnv, 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 d075b6b25..d09bacaf3 100644 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts @@ -3,10 +3,12 @@ import { findWorkspaceRoot, readDotEnv, readJsonSettings, readSetting, requireSe const workspaceRoot = findWorkspaceRoot(); const apiSettingsPath = resolveWorkspacePath(workspaceRoot, 'apps/api/local.settings.json'); -const uiEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiCommunityEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiStaffEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-staff/.env'); const apiValues = readJsonSettings(apiSettingsPath); -const uiValues = readDotEnv(uiEnvPath); +const uiCommunityValues = readDotEnv(uiCommunityEnvPath); +const uiStaffValues = readDotEnv(uiStaffEnvPath); /** * Defaults for E2E/acceptance test settings when local.settings.json is absent @@ -41,12 +43,17 @@ export const apiSettings = { apiDir: path.dirname(apiSettingsPath), oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), - uiCommunityDir: path.dirname(uiEnvPath), - uiStaffDir: path.join(workspaceRoot, 'apps', 'ui-staff'), + uiCommunityDir: path.dirname(uiCommunityEnvPath), + uiStaffDir: path.dirname(uiStaffEnvPath), } as const; -export const uiSettings = { - baseUrl: requireSetting(uiValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in .env'), +export const uiCommunitySettings = { + baseUrl: requireSetting(uiCommunityValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in apps/ui-community/.env'), - graphqlEndpoint: requireSetting(uiValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in .env'), + graphqlEndpoint: requireSetting(uiCommunityValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-community/.env'), +} as const; + +export const uiStaffSettings = { + baseUrl: readSetting(uiStaffValues, 'VITE_APP_UI_STAFF_BASE_URL', 'https://staff.ownercommunity.localhost:1355') ?? 'https://staff.ownercommunity.localhost:1355', + graphqlEndpoint: requireSetting(uiStaffValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-staff/.env'), } as const; diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 5c1a7f51d..0b599e903 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -26,9 +26,18 @@ const guest: TestActor = { familyName: '', }; +const staffUser: TestActor = { + name: 'StaffUser', + externalId: '10000000-0000-4000-8000-000000000001', + email: 'staff@sharethrift.onmicrosoft.com', + givenName: 'Staff', + familyName: 'User', +}; + export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, + StaffUser: staffUser, Guest: guest, } as const; diff --git a/packages/ocom/application-services/src/contexts/user/index.ts b/packages/ocom/application-services/src/contexts/user/index.ts index e7a3c1f62..6841b047b 100644 --- a/packages/ocom/application-services/src/contexts/user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/index.ts @@ -1,15 +1,18 @@ import type { DataSources } from '@ocom/persistence'; import { EndUser as EndUserApi, type EndUserApplicationService } from './end-user/index.ts'; import { StaffRole as StaffRoleApi, type StaffRoleApplicationService } from './staff-role/index.ts'; +import { StaffUser as StaffUserApi, type StaffUserApplicationService } from './staff-user/index.ts'; export interface UserContextApplicationService { EndUser: EndUserApplicationService; StaffRole: StaffRoleApplicationService; + StaffUser: StaffUserApplicationService; } export const User = (dataSources: DataSources): UserContextApplicationService => { return { EndUser: EndUserApi(dataSources), StaffRole: StaffRoleApi(dataSources), + StaffUser: StaffUserApi(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts new file mode 100644 index 000000000..c2f7eccd3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts @@ -0,0 +1,493 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { createDefaultRoles, StaffAppRoleNames } from './create-default-roles.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-default-roles.feature')); + +type StaffRolePermissions = { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + userPermissions: { canManageUsers: boolean }; +}; + +function makeMockStaffRole( + roleName: string, + permissions: StaffRolePermissions = { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, +): Domain.Contexts.User.StaffRole.StaffRole { + return { + id: `id-${roleName}`, + roleName, + isDefault: false, + permissions, + roleType: null, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRole; +} + +function makeMockRepo(existingRoleNames: string[] = [], overrides: Partial = {}): StaffRoleRepo { + const savedRoles: Domain.Contexts.User.StaffRole.StaffRole[] = []; + + return { + // biome-ignore lint/suspicious/noExplicitAny: test helper captures saved roles for inspection + _savedRoles: savedRoles as any, + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + if (existingRoleNames.includes(roleName)) { + return Promise.resolve(makeMockStaffRole(roleName)); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + if (existingRoleNames.includes(enterpriseAppRole)) { + return Promise.resolve(makeMockStaffRole(enterpriseAppRole)); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultCaseManagerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.CaseManager, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.ServiceLineOwner, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultFinanceInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.Finance, { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultTechAdminInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.TechAdmin, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: true }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: true }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => { + return Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + }), + ...overrides, + } as unknown as StaffRoleRepo; +} + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +function makeDataSources(repo: StaffRoleRepo): DataSources { + // Ensure compatibility for tests that only stub getNewInstance by mapping new factory methods to it when missing + const repoWithDefaults = { ...repo } as StaffRoleRepo; + if (!repoWithDefaults.getNewDefaultCaseManagerInstance) { + repoWithDefaults.getNewDefaultCaseManagerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.CaseManager); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultServiceLineOwnerInstance) { + repoWithDefaults.getNewDefaultServiceLineOwnerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.ServiceLineOwner); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultFinanceInstance) { + repoWithDefaults.getNewDefaultFinanceInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.Finance); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = false; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = false; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultTechAdminInstance) { + repoWithDefaults.getNewDefaultTechAdminInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.TechAdmin); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }).canManageCommunities = true; + (role.permissions.communityPermissions as { canManageStaffRolesAndPermissions?: boolean }).canManageStaffRolesAndPermissions = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = true; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getDefaultRoleByEnterpriseAppRole) { + repoWithDefaults.getDefaultRoleByEnterpriseAppRole = (enterpriseAppRole: string) => repoWithDefaults.getByRoleName(enterpriseAppRole); + } + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, callback: (r: StaffRoleRepo) => Promise) => { + await callback(repoWithDefaults as unknown as StaffRoleRepo); + }), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let mockRepo: StaffRoleRepo; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + + BeforeEachScenario(() => { + result = []; + mockRepo = undefined as unknown as typeof mockRepo; + dataSources = undefined as unknown as DataSources; + }); + + // ─── All four missing ────────────────────────────────────────────────────── + + Scenario('Creates all four default roles when none exist', ({ Given, When, Then, And }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin"', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should return all four created role references', () => { + expect(result).toHaveLength(4); + for (const r of result) expect(r.isDefault).toBe(true); + }); + }); + + // ─── Partial skip ───────────────────────────────────────────────────────── + + Scenario('Skips roles that already exist', ({ Given, When, Then, And }) => { + Given('the role "Default.CaseManager" already exists', () => { + mockRepo = makeMockRepo([StaffAppRoleNames.CaseManager]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should only create the three missing roles', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should not attempt to create "Default.CaseManager" again', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + }); + }); + + // ─── All exist ──────────────────────────────────────────────────────────── + + Scenario('Returns empty array when all roles already exist', ({ Given, When, Then, And }) => { + Given('all four default roles already exist', () => { + mockRepo = makeMockRepo(Object.values(StaffAppRoleNames)); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should return an empty array', () => { + expect(result).toHaveLength(0); + }); + + And('it should not call getNewInstance or save', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.save)).not.toHaveBeenCalled(); + }); + }); + + // ─── CaseManager permissions ────────────────────────────────────────────── + + Scenario('CaseManager role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.CaseManager" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.CaseManager" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── Finance permissions ────────────────────────────────────────────────── + + Scenario('Finance role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.Finance" role should have canManageCommunities false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + + And('the "Default.Finance" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.Finance" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.Finance" role should have canManageUsers false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.userPermissions.canManageUsers).toBe(false); + }); + }); + + // ─── TechAdmin permissions ──────────────────────────────────────────────── + + Scenario('TechAdmin role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.TechAdmin" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role?.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageTechAdmin true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── ServiceLineOwner permissions ───────────────────────────────────────── + + Scenario('ServiceLineOwner role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.ServiceLineOwner" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.ServiceLineOwner" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── isDefault false ────────────────────────────────────────────────────── + + Scenario('All created roles have isDefault set to true', ({ Given, When, Then }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('all created roles should have isDefault true', () => { + for (const role of result) { + expect(role.isDefault).toBe(true); + } + }); + }); + + // ─── Error propagation ──────────────────────────────────────────────────── + + Scenario('Propagates unexpected repository errors', ({ Given, When, Then }) => { + let thrownError: unknown; + + Given('no staff roles exist', () => { + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('Database connection failed')), + getNewInstance: vi.fn(), + save: vi.fn(), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('the repository throws an unexpected error', async () => { + try { + await createDefaultRoles(dataSources)(); + } catch (error) { + thrownError = error; + } + }); + + Then('createDefaultRoles should propagate the error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Database connection failed'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts new file mode 100644 index 000000000..ee8426a2e --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts @@ -0,0 +1,47 @@ +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +export const StaffAppRoleNames = Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames; + +const roleExists = async (repository: StaffRoleRepo, enterpriseAppRole: string): Promise => { + try { + await repository.getDefaultRoleByEnterpriseAppRole(enterpriseAppRole); + return true; + } catch (error) { + if (error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found'))) { + return false; + } + throw error; + } +}; + +const roleDefinitions: ReadonlyArray<{ + enterpriseAppRole: string; + factory: (repo: StaffRoleRepo) => Promise>; +}> = [ + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.CaseManager, factory: (repo) => repo.getNewDefaultCaseManagerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.ServiceLineOwner, factory: (repo) => repo.getNewDefaultServiceLineOwnerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.Finance, factory: (repo) => repo.getNewDefaultFinanceInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.TechAdmin, factory: (repo) => repo.getNewDefaultTechAdminInstance() }, +]; + +export const createDefaultRoles = (dataSources: DataSources) => { + return async (): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }); + const created: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] = []; + + for (const { enterpriseAppRole, factory } of roleDefinitions) { + let saved: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + if (await roleExists(repository, enterpriseAppRole)) return; + const role = await factory(repository); + saved = await repository.save(role); + }); + if (saved) created.push(saved); + } + + return created; + }; +}; \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature new file mode 100644 index 000000000..83892960d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature @@ -0,0 +1,61 @@ +Feature: Creating default staff roles + + Scenario: Creates all four default roles when none exist + Given no staff roles exist + When I call createDefaultRoles + Then it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin" + And it should return all four created role references + + Scenario: Skips roles that already exist + Given the role "Default.CaseManager" already exists + When I call createDefaultRoles + Then it should only create the three missing roles + And it should not attempt to create "Default.CaseManager" again + + Scenario: Returns empty array when all roles already exist + Given all four default roles already exist + When I call createDefaultRoles + Then it should return an empty array + And it should not call getNewInstance or save + + Scenario: CaseManager role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.CaseManager" role should have canManageCommunities true + And the "Default.CaseManager" role should have canManageFinance false + And the "Default.CaseManager" role should have canManageTechAdmin false + And the "Default.CaseManager" role should have canManageUsers true + + Scenario: Finance role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.Finance" role should have canManageCommunities false + And the "Default.Finance" role should have canManageFinance true + And the "Default.Finance" role should have canManageTechAdmin false + And the "Default.Finance" role should have canManageUsers false + + Scenario: TechAdmin role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.TechAdmin" role should have canManageCommunities true + And the "Default.TechAdmin" role should have canManageFinance true + And the "Default.TechAdmin" role should have canManageTechAdmin true + And the "Default.TechAdmin" role should have canManageUsers true + + Scenario: ServiceLineOwner role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.ServiceLineOwner" role should have canManageCommunities true + And the "Default.ServiceLineOwner" role should have canManageFinance false + And the "Default.ServiceLineOwner" role should have canManageTechAdmin false + And the "Default.ServiceLineOwner" role should have canManageUsers true + + Scenario: All created roles have isDefault set to true + Given no staff roles exist + When I call createDefaultRoles + Then all created roles should have isDefault true + + Scenario: Propagates unexpected repository errors + Given no staff roles exist + When the repository throws an unexpected error + Then createDefaultRoles should propagate the error diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts index 40c815cac..e032256e8 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts @@ -1,12 +1,14 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { create, type StaffRoleCreateCommand } from './create.ts'; +import { createDefaultRoles } from './create-default-roles.ts'; import { deleteAndReassign, type StaffRoleDeleteAndReassignCommand } from './delete-and-reassign.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; + createDefaultRoles: () => Promise; deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; @@ -15,6 +17,7 @@ export interface StaffRoleApplicationService { export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService => { return { create: create(dataSources), + createDefaultRoles: createDefaultRoles(dataSources), deleteAndReassign: deleteAndReassign(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts new file mode 100644 index 000000000..573d541b9 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts @@ -0,0 +1,431 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { StaffAppRoleNames } from '../staff-role/create-default-roles.ts'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-if-not-exists.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeMockStaffRoleRef(roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id: `role-id-${roleName}`, + roleName, + enterpriseAppRole: roleName, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockNewUser(externalId: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined = undefined; + return { + id: `new-id-${externalId}`, + externalId, + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + displayName: 'First Last', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + existingUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + newUser?: MockStaffUserInstance; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + roleByEnterpriseAppRole?: Record; + saveShouldFail?: boolean; +}): DataSources { + const newUser = overrides.newUser ?? makeMockNewUser('default'); + const savedUser = overrides.savedUser ?? (newUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference); + + const staffUserRepo = { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + getNewInstance: vi.fn().mockResolvedValue(newUser), + save: overrides.saveShouldFail ? vi.fn().mockResolvedValue(undefined) : vi.fn().mockResolvedValue(savedUser), + delete: vi.fn(), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + const role = Object.values(overrides.roleByEnterpriseAppRole ?? {}).find((candidate) => candidate.roleName === roleName); + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + const role = overrides.roleByEnterpriseAppRole?.[enterpriseAppRole]; + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((name: string) => Promise.resolve(makeMockStaffRoleRef(name))), + getNewDefaultCaseManagerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.CaseManager)), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.ServiceLineOwner)), + getNewDefaultFinanceInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.Finance)), + getNewDefaultTechAdminInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin)), + save: vi.fn().mockImplementation((r: unknown) => Promise.resolve(r)), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + }, + }, + }, + }, + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: typeof Object; _staffRoleRepo?: typeof Object }; + let command: StaffUserCreateIfNotExistsCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let newUser: MockStaffUserInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + existingUser = null; + newUser = makeMockNewUser('default'); + command = { + externalId: 'ext-default', + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + aadRoles: [], + }; + }); + + // ─── Returns existing user ──────────────────────────────────────────────── + + Scenario('Returns existing user when user already exists', ({ Given, When, Then, And }) => { + Given('a staff user with externalId "ext-123" already exists', () => { + existingUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources({ existingUser }); + command = { ...command, externalId: 'ext-123' }; + }); + + When('I call createIfNotExists with externalId "ext-123"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should return the existing user', () => { + expect(result).toBe(existingUser); + }); + + And('it should not create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).not.toHaveBeenCalled(); + }); + }); + + // ─── Creates new user (no role) ─────────────────────────────────────────── + + Scenario('Creates a new user when user does not exist', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-456" exists', () => { + newUser = makeMockNewUser('ext-456'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-456', aadRoles: [] }; + }); + + And('no matching AAD role is provided', () => { + // aadRoles is already [] + }); + + When('I call createIfNotExists with externalId "ext-456"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should call createDefaultRoles', () => { + const roleUow = ( + dataSources as unknown as { + domainDataSource: { User: { StaffRole: { StaffRoleUnitOfWork: { withTransaction: ReturnType } } } }; + } + ).domainDataSource.User.StaffRole.StaffRoleUnitOfWork; + expect(roleUow.withTransaction).toHaveBeenCalled(); + }); + + And('it should create a new user with the provided details', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-456', 'First', 'Last', 'first@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-456'); + }); + }); + + // ─── Assigns matching role ──────────────────────────────────────────────── + + Scenario('Creates a new user with a matching role when AAD role matches', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-789" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-789'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.CaseManager': roleRef }, + }); + command = { ...command, externalId: 'ext-789' }; + }); + + And('the AAD roles include "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Staff.CaseManager'] }; + }); + + And('the "Staff.CaseManager" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-789"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Staff.CaseManager" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.CaseManager); + }); + }); + + Scenario('Assigns Default.TechAdmin when AAD role is enterprise app role', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-201" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + newUser = makeMockNewUser('ext-201'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.TechAdmin': roleRef }, + }); + command = { ...command, externalId: 'ext-201' }; + }); + + And('the AAD roles include "Staff.TechAdmin"', () => { + command = { ...command, aadRoles: ['Staff.TechAdmin'] }; + }); + + And('the "Default.TechAdmin" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-201"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Assigns highest priority matching role when multiple AAD roles are provided', ({ Given, When, Then, And }) => { + let techAdminRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + let caseManagerRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-202" exists', () => { + techAdminRole = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + caseManagerRole = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-202'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { + 'Staff.TechAdmin': techAdminRole, + 'Staff.CaseManager': caseManagerRole, + }, + }); + command = { ...command, externalId: 'ext-202' }; + }); + + And('the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Unknown.Role', 'Staff.TechAdmin', 'Staff.CaseManager'] }; + }); + + And('the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository', () => { + // roles were set up in Given + }); + + When('I call createIfNotExists with externalId "ext-202"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Creates a new user without a role when AAD role has alternate formatting', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-203" exists', () => { + newUser = makeMockNewUser('ext-203'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-203' }; + }); + + And('the AAD roles include "default tech admin"', () => { + command = { ...command, aadRoles: ['default tech admin'] }; + }); + + When('I call createIfNotExists with externalId "ext-203"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when AAD role unknown ──────────────────────────────────────── + + Scenario('Creates a new user without a role when no AAD role matches', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-000" exists', () => { + newUser = makeMockNewUser('ext-000'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-000' }; + }); + + And('the AAD roles include "Unknown.Role"', () => { + command = { ...command, aadRoles: ['Unknown.Role'] }; + }); + + When('I call createIfNotExists with externalId "ext-000"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when empty AAD roles ──────────────────────────────────────── + + Scenario('Creates a new user without a role when AAD roles list is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-111" exists', () => { + newUser = makeMockNewUser('ext-111'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-111' }; + }); + + And('the AAD roles list is empty', () => { + command = { ...command, aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-111"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── Throws when save returns undefined ─────────────────────────────────── + + Scenario('Throws when repository fails to save the new user', ({ Given, When, Then }) => { + Given('no staff user with externalId "ext-err" exists', () => { + // save returns undefined to simulate a failed save (createdUser stays undefined) + newUser = makeMockNewUser('ext-err'); + dataSources = makeDataSources({ existingUser: null, newUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-err', aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-err"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to create staff user"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to create staff user'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts new file mode 100644 index 000000000..98b25204c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts @@ -0,0 +1,66 @@ +import type { Domain } from '@ocom/domain'; +import { Domain as DomainRuntime } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createDefaultRoles } from '../staff-role/create-default-roles.ts'; + +export interface StaffUserCreateIfNotExistsCommand { + externalId: string; + firstName: string; + lastName: string; + email: string; + aadRoles: string[]; +} + +const isNotFoundError = (error: unknown): error is Error => { + return error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found')); +}; + +const getDefaultRoleByHighestPriorityEnterpriseAppRole = async (dataSources: DataSources, aadRoles: string[]): Promise => { + let found: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repo) => { + for (const aadRole of aadRoles) { + try { + found = await repo.getDefaultRoleByEnterpriseAppRole(aadRole); + return; + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + } + }); + return found; +}; + +export const createIfNotExists = (dataSources: DataSources) => { + return async (command: StaffUserCreateIfNotExistsCommand): Promise => { + const existing = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + if (existing) { + return existing; + } + + // Ensure the 4 default roles exist before creating the user + await createDefaultRoles(dataSources)(); + + const matchingRole = await getDefaultRoleByHighestPriorityEnterpriseAppRole(dataSources, command.aadRoles); + + let createdUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), async (repository) => { + const newUser = await repository.getNewInstance(command.externalId, command.firstName, command.lastName, command.email); + + if (matchingRole) { + newUser.role = matchingRole; + } + + createdUser = await repository.save(newUser); + }); + + if (!createdUser) { + throw new Error('Unable to create staff user'); + } + + return createdUser; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature new file mode 100644 index 000000000..fb4c902e5 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature @@ -0,0 +1,59 @@ +Feature: Create staff user if not exists + + Scenario: Returns existing user when user already exists + Given a staff user with externalId "ext-123" already exists + When I call createIfNotExists with externalId "ext-123" + Then it should return the existing user + And it should not create a new user + + Scenario: Creates a new user when user does not exist + Given no staff user with externalId "ext-456" exists + And no matching AAD role is provided + When I call createIfNotExists with externalId "ext-456" + Then it should call createDefaultRoles + And it should create a new user with the provided details + And it should return the newly created user + + Scenario: Creates a new user with a matching role when AAD role matches + Given no staff user with externalId "ext-789" exists + And the AAD roles include "Staff.CaseManager" + And the "Staff.CaseManager" role exists in the repository + When I call createIfNotExists with externalId "ext-789" + Then it should assign the "Staff.CaseManager" role to the new user + + Scenario: Assigns Default.TechAdmin when AAD role is enterprise app role + Given no staff user with externalId "ext-201" exists + And the AAD roles include "Staff.TechAdmin" + And the "Default.TechAdmin" role exists in the repository + When I call createIfNotExists with externalId "ext-201" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Assigns highest priority matching role when multiple AAD roles are provided + Given no staff user with externalId "ext-202" exists + And the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager" + And the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository + When I call createIfNotExists with externalId "ext-202" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Creates a new user without a role when AAD role has alternate formatting + Given no staff user with externalId "ext-203" exists + And the AAD roles include "default tech admin" + When I call createIfNotExists with externalId "ext-203" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when no AAD role matches + Given no staff user with externalId "ext-000" exists + And the AAD roles include "Unknown.Role" + When I call createIfNotExists with externalId "ext-000" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when AAD roles list is empty + Given no staff user with externalId "ext-111" exists + And the AAD roles list is empty + When I call createIfNotExists with externalId "ext-111" + Then it should create the user without assigning a role + + Scenario: Throws when repository fails to save the new user + Given no staff user with externalId "ext-err" exists + When I call createIfNotExists with externalId "ext-err" + Then it should throw an error with message "Unable to create staff user" diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature new file mode 100644 index 000000000..097572fd4 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature @@ -0,0 +1,11 @@ +Feature: Query staff user by external ID + + Scenario: Returns a staff user when the external ID exists + Given a staff user with externalId "ext-123" exists in the read repository + When I call queryByExternalId with externalId "ext-123" + Then it should return the matching staff user + + Scenario: Returns null when no staff user matches the external ID + Given no staff user with externalId "ext-missing" exists in the read repository + When I call queryByExternalId with externalId "ext-missing" + Then it should return null diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts new file mode 100644 index 000000000..2c5b0d00b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts @@ -0,0 +1,16 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; +import { queryByExternalId, type StaffUserQueryByExternalIdCommand } from './query-by-external-id.ts'; + +export interface StaffUserApplicationService { + createIfNotExists: (command: StaffUserCreateIfNotExistsCommand) => Promise; + queryByExternalId: (command: StaffUserQueryByExternalIdCommand) => Promise; +} + +export const StaffUser = (dataSources: DataSources): StaffUserApplicationService => { + return { + createIfNotExists: createIfNotExists(dataSources), + queryByExternalId: queryByExternalId(dataSources), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts new file mode 100644 index 000000000..2cbcc5f4d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { queryByExternalId } from './query-by-external-id.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/query-by-external-id.feature')); + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(existingUser), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario }) => { + Scenario('Returns a staff user when the external ID exists', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let expectedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + + Given('a staff user with externalId "ext-123" exists in the read repository', () => { + expectedUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources(expectedUser); + }); + + When('I call queryByExternalId with externalId "ext-123"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-123' }); + }); + + Then('it should return the matching staff user', () => { + expect(result).toBe(expectedUser); + expect(result?.externalId).toBe('ext-123'); + }); + }); + + Scenario('Returns null when no staff user matches the external ID', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + + Given('no staff user with externalId "ext-missing" exists in the read repository', () => { + dataSources = makeDataSources(null); + }); + + When('I call queryByExternalId with externalId "ext-missing"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts new file mode 100644 index 000000000..23cd50e5f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserQueryByExternalIdCommand { + externalId: string; +} + +export const queryByExternalId = (dataSources: DataSources) => { + return async (command: StaffUserQueryByExternalIdCommand): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + }; +}; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index ccfdc57d1..58e37f066 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -59,8 +59,7 @@ export const buildApplicationServicesFactory = (infrastructureServicesRegistry: passport = Domain.PassportFactory.forMember(endUser, member, community); } } else if (openIdConfigKey === 'StaffPortal') { - const staffUser = undefined; - // const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); + const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); if (staffUser) { passport = Domain.PassportFactory.forStaffUser(staffUser); } diff --git a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts index 43a2cd4ee..da291d9ed 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts @@ -1,6 +1,8 @@ import { type Model, type ObjectId, Schema, type SchemaDefinition } from 'mongoose'; import { type Role, type RoleModelType, roleOptions } from './role.model.ts'; +export const StaffEnterpriseAppRoles = ['Staff.CaseManager', 'Staff.Finance', 'Staff.ServiceLineOwner', 'Staff.TechAdmin'] as const; + export interface StaffRoleServicePermissions { id?: ObjectId; canManageServices: boolean; @@ -12,6 +14,7 @@ export interface StaffRoleServiceTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -21,6 +24,7 @@ export interface StaffRoleViolationTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -34,6 +38,7 @@ export interface StaffRolePropertyPermissions { export interface StaffRoleCommunityPermissions { id?: ObjectId; + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -41,12 +46,37 @@ export interface StaffRoleCommunityPermissions { canReIndexSearchCollections: boolean; } +export interface StaffRoleFinancePermissions { + id?: ObjectId; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleTechAdminPermissions { + id?: ObjectId; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleUserPermissions { + id?: ObjectId; + canManageUsers: boolean; +} + export interface StaffRolePermissions { id?: ObjectId; servicePermissions: StaffRoleServicePermissions; serviceTicketPermissions: StaffRoleServiceTicketPermissions; violationTicketPermissions: StaffRoleViolationTicketPermissions; communityPermissions: StaffRoleCommunityPermissions; + financePermissions: StaffRoleFinancePermissions; + techAdminPermissions: StaffRoleTechAdminPermissions; + userPermissions: StaffRoleUserPermissions; propertyPermissions: StaffRolePropertyPermissions; } @@ -54,6 +84,7 @@ export interface StaffRole extends Role { permissions: StaffRolePermissions; roleName: string; + enterpriseAppRole?: string; roleType?: string; isDefault: boolean; } @@ -68,15 +99,18 @@ const StaffRoleSchema = new Schema, StaffRole>( canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, violationTicketPermissions: { canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, communityPermissions: { + canManageCommunities: { type: Boolean, required: true, default: false }, canManageStaffRolesAndPermissions: { type: Boolean, required: true, @@ -99,19 +133,40 @@ const StaffRoleSchema = new Schema, StaffRole>( default: false, }, } as SchemaDefinition, + financePermissions: { + canManageFinance: { type: Boolean, required: true, default: false }, + canViewGLBatchSummaries: { type: Boolean, required: true, default: false }, + canViewFinanceConfigs: { type: Boolean, required: true, default: false }, + canCreateFinanceConfigs: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + techAdminPermissions: { + canManageTechAdmin: { type: Boolean, required: true, default: false }, + canViewDatabaseExplorer: { type: Boolean, required: true, default: false }, + canViewBlobExplorer: { type: Boolean, required: true, default: false }, + canViewQueueDashboard: { type: Boolean, required: true, default: false }, + canSendQueueMessages: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + userPermissions: { + canManageUsers: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, propertyPermissions: { - // canManageProperties: { type: Boolean, required: true, default: false }, - // canEditOwnProperty: { type: Boolean, required: true, default: false }, + canManageProperties: { type: Boolean, required: true, default: false }, + canEditOwnProperty: { type: Boolean, required: true, default: false }, } as SchemaDefinition, } as SchemaDefinition, - schemaVersion: { type: String, default: '1.0.0' }, - roleName: { type: String, required: true, maxlength: 50 }, + schemaVersion: { type: String, default: '1.0.0', immutable: true }, + roleName: { type: String, required: true, maxlength: 256 }, + enterpriseAppRole: { + type: String, + required: true, + enum: StaffEnterpriseAppRoles, + }, isDefault: { type: Boolean, required: true, default: false }, }, roleOptions, ).index({ roleName: 1 }, { unique: true }); -export const StaffRoleModelName: string = 'staff-roles'; +export const StaffRoleModelName: string = 'staff-user-role'; export const StaffRoleModelFactory = (RoleModel: RoleModelType) => { return RoleModel.discriminator(StaffRoleModelName, StaffRoleSchema); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature index 547bbc02c..ba7c3c123 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature @@ -77,4 +77,18 @@ Feature: StaffRoleCommunityPermissions Scenario: Changing canReIndexSearchCollections without permission Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account When I try to set canReIndexSearchCollections to true - Then a PermissionError should be thrown \ No newline at end of file + Then a PermissionError should be thrown + Scenario: Changing canManageCommunities with manage staff roles permission + Given a StaffRoleCommunityPermissions entity with permission to manage staff roles + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities with system account permission + Given a StaffRoleCommunityPermissions entity with system account permission + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities without permission + Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account + When I try to set canManageCommunities to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature new file mode 100644 index 000000000..1d1b1f4dc --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature @@ -0,0 +1,50 @@ +Feature: StaffRoleFinancePermissions + + Background: + Given valid StaffRoleFinancePermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageFinance with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance with system account permission + Given a StaffRoleFinancePermissions entity with system account permission + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canManageFinance to true + Then a PermissionError should be thrown + + Scenario: Changing canViewGLBatchSummaries with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewGLBatchSummaries to true + Then the property should be updated to true + + Scenario: Changing canViewGLBatchSummaries without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewGLBatchSummaries to true + Then a PermissionError should be thrown + + Scenario: Changing canViewFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canViewFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewFinanceConfigs to true + Then a PermissionError should be thrown + + Scenario: Changing canCreateFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canCreateFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canCreateFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canCreateFinanceConfigs to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature index aae26b8e3..901786338 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature @@ -27,4 +27,19 @@ Feature: StaffRolePermissions Scenario: Accessing violationTicketPermissions Given a StaffRolePermissions entity When I access the violationTicketPermissions property - Then I should receive a StaffRoleViolationTicketPermissions entity instance \ No newline at end of file + Then I should receive a StaffRoleViolationTicketPermissions entity instance + + Scenario: Accessing financePermissions + Given a StaffRolePermissions entity + When I access the financePermissions property + Then I should receive a StaffRoleFinancePermissions entity instance + + Scenario: Accessing techAdminPermissions + Given a StaffRolePermissions entity + When I access the techAdminPermissions property + Then I should receive a StaffRoleTechAdminPermissions entity instance + + Scenario: Accessing userPermissions + Given a StaffRolePermissions entity + When I access the userPermissions property + Then I should receive a StaffRoleUserPermissions entity instance diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature new file mode 100644 index 000000000..33816afec --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature @@ -0,0 +1,68 @@ +Feature: StaffRoleTechAdminPermissions + + Background: + Given valid StaffRoleTechAdminPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageTechAdmin with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin with system account permission + Given a StaffRoleTechAdminPermissions entity with system account permission + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canManageTechAdmin to true + Then a PermissionError should be thrown + + Scenario: Changing canViewDatabaseExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewDatabaseExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewDatabaseExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewDatabaseExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewBlobExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewBlobExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewBlobExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewBlobExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewQueueDashboard with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewQueueDashboard to true + Then the property should be updated to true + + Scenario: Changing canViewQueueDashboard without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewQueueDashboard to true + Then a PermissionError should be thrown + + Scenario: Changing canSendQueueMessages with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canSendQueueMessages to true + Then the property should be updated to true + + Scenario: Changing canSendQueueMessages without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canSendQueueMessages to true + Then a PermissionError should be thrown + + Scenario: Reading tech admin permission flags + Given a StaffRoleTechAdminPermissions entity with all permission flags set to true + Then canManageTechAdmin should be true + And canViewDatabaseExplorer should be true + And canViewBlobExplorer should be true + And canViewQueueDashboard should be true + And canSendQueueMessages should be true diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature new file mode 100644 index 000000000..21cd6b942 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature @@ -0,0 +1,20 @@ +Feature: StaffRoleUserPermissions + + Background: + Given valid StaffRoleUserPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageUsers with manage staff roles permission + Given a StaffRoleUserPermissions entity with permission to manage staff roles + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers with system account permission + Given a StaffRoleUserPermissions entity with system account permission + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers without permission + Given a StaffRoleUserPermissions entity without permission to manage staff roles or system account + When I try to set canManageUsers to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature index d5965aff4..0dad1edde 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature @@ -78,3 +78,22 @@ Feature: StaffRole And the createdAt property should return the correct date And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + + # getDefaultRoleNames + Scenario: Getting default role names + When I call getDefaultRoleNames + Then the result should contain "Default.CaseManager" + And the result should contain "Default.ServiceLineOwner" + And the result should contain "Default.Finance" + And the result should contain "Default.TechAdmin" + And the result should have exactly 4 names + + Scenario: Creating a default tech admin role + When I create a default tech admin staff role + Then the roleName should be "Default Tech Admin" + And the enterpriseAppRole should be "Staff.TechAdmin" + And the tech admin role should allow managing communities + And the tech admin role should allow managing staff roles and permissions + And the tech admin role should allow managing finance + And the tech admin role should allow managing tech admin + And the tech admin role should allow managing users diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts index b86eaf818..0724acbd8 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts @@ -5,10 +5,15 @@ export type { } from './staff-role.ts'; export { StaffRole } from './staff-role.ts'; export type { StaffRoleUnitOfWork } from './staff-role.uow.ts'; +export * as StaffRoleValueObjects from './staff-role.value-objects.ts'; export type { StaffRoleCommunityPermissionsEntityReference, StaffRoleCommunityPermissionsProps, } from './staff-role-community-permissions.ts'; +export type { + StaffRoleFinancePermissionsEntityReference, + StaffRoleFinancePermissionsProps, +} from './staff-role-finance-permissions.ts'; export type { StaffRolePermissionsEntityReference, StaffRolePermissionsProps, @@ -25,6 +30,14 @@ export type { StaffRoleServiceTicketPermissionsEntityReference, StaffRoleServiceTicketPermissionsProps, } from './staff-role-service-ticket-permissions.ts'; +export type { + StaffRoleTechAdminPermissionsEntityReference, + StaffRoleTechAdminPermissionsProps, +} from './staff-role-tech-admin-permissions.ts'; +export type { + StaffRoleUserPermissionsEntityReference, + StaffRoleUserPermissionsProps, +} from './staff-role-user-permissions.ts'; export type { StaffRoleViolationTicketPermissionsEntityReference, StaffRoleViolationTicketPermissionsProps, diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts index dae792d73..0d3d58952 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts @@ -18,6 +18,7 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -311,4 +312,48 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(setWithoutPermission).toThrow('Cannot set permission'); }); }); + + // canManageCommunities + Scenario('Changing canManageCommunities with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I try to set canManageCommunities to true', () => { + setWithoutPermission = () => { + entity.canManageCommunities = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts index c8887d0fc..fbc58c0dc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts @@ -4,6 +4,7 @@ import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; interface StaffRoleCommunityPermissionsSpec { + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -28,6 +29,13 @@ export class StaffRoleCommunityPermissions extends ValueObject ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + const emptyPermissions = { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + }, + userPermissions: { + canManageUsers: false, + }, + } as const; + + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: emptyPermissions as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +test('applyDefaultSpec sets CaseManager permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets Finance permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultFinanceInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets ServiceLineOwner permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets TechAdmin permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultTechAdminInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts new file mode 100644 index 000000000..e46d32f40 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-finance-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleFinancePermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleFinancePermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleFinancePermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageFinance with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canManageFinance to true', () => { + setWithoutPermission = () => { + entity.canManageFinance = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewGLBatchSummaries with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewGLBatchSummaries to true', () => { + entity.canViewGLBatchSummaries = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Changing canViewGLBatchSummaries without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewGLBatchSummaries to true', () => { + setWithoutPermission = () => { + entity.canViewGLBatchSummaries = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewFinanceConfigs to true', () => { + entity.canViewFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canViewFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canViewFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canCreateFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canCreateFinanceConfigs to true', () => { + entity.canCreateFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canCreateFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canCreateFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canCreateFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canCreateFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts new file mode 100644 index 000000000..e07d6be7d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleFinancePermissionsSpec { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleFinancePermissionsProps extends StaffRoleFinancePermissionsSpec, ValueObjectProps {} +export interface StaffRoleFinancePermissionsEntityReference extends Readonly {} + +export class StaffRoleFinancePermissions extends ValueObject implements StaffRoleFinancePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleFinancePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageFinance(): boolean { + return this.props.canManageFinance; + } + set canManageFinance(value: boolean) { + this.validateVisa(); + this.props.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.props.canViewGLBatchSummaries; + } + set canViewGLBatchSummaries(value: boolean) { + this.validateVisa(); + this.props.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.props.canViewFinanceConfigs; + } + set canViewFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.props.canCreateFinanceConfigs; + } + set canCreateFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canCreateFinanceConfigs = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts index b09ad76ed..c73dcecf7 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts @@ -3,10 +3,13 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect, vi } from 'vitest'; import { StaffRoleCommunityPermissions } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; import { StaffRolePermissions } from './staff-role-permissions.ts'; import { StaffRolePropertyPermissions } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions } from './staff-role-violation-ticket-permissions.ts'; const test = { for: describeFeature }; @@ -26,6 +29,9 @@ function makeProps() { serviceTicketPermissions: {} as StaffRoleServiceTicketPermissions, servicePermissions: {} as StaffRoleServicePermissions, violationTicketPermissions: {} as StaffRoleViolationTicketPermissions, + financePermissions: {} as StaffRoleFinancePermissions, + techAdminPermissions: {} as StaffRoleTechAdminPermissions, + userPermissions: {} as StaffRoleUserPermissions, }; } @@ -113,4 +119,43 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(violationTicketPermissions).toBeInstanceOf(StaffRoleViolationTicketPermissions); }); }); + + Scenario('Accessing financePermissions', ({ Given, When, Then }) => { + let financePermissions: StaffRoleFinancePermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the financePermissions property', () => { + financePermissions = entity.financePermissions; + }); + Then('I should receive a StaffRoleFinancePermissions entity instance', () => { + expect(financePermissions).toBeInstanceOf(StaffRoleFinancePermissions); + }); + }); + + Scenario('Accessing techAdminPermissions', ({ Given, When, Then }) => { + let techAdminPermissions: StaffRoleTechAdminPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the techAdminPermissions property', () => { + techAdminPermissions = entity.techAdminPermissions; + }); + Then('I should receive a StaffRoleTechAdminPermissions entity instance', () => { + expect(techAdminPermissions).toBeInstanceOf(StaffRoleTechAdminPermissions); + }); + }); + + Scenario('Accessing userPermissions', ({ Given, When, Then }) => { + let userPermissions: StaffRoleUserPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the userPermissions property', () => { + userPermissions = entity.userPermissions; + }); + Then('I should receive a StaffRoleUserPermissions entity instance', () => { + expect(userPermissions).toBeInstanceOf(StaffRoleUserPermissions); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts index 22e8ee188..7e45a39f6 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts @@ -1,10 +1,13 @@ -import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; import { StaffRoleCommunityPermissions, type StaffRoleCommunityPermissionsEntityReference, type StaffRoleCommunityPermissionsProps } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions, type StaffRoleFinancePermissionsEntityReference, type StaffRoleFinancePermissionsProps } from './staff-role-finance-permissions.ts'; import { StaffRolePropertyPermissions, type StaffRolePropertyPermissionsEntityReference, type StaffRolePropertyPermissionsProps } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions, type StaffRoleServicePermissionsEntityReference, type StaffRoleServicePermissionsProps } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions, type StaffRoleServiceTicketPermissionsEntityReference, type StaffRoleServiceTicketPermissionsProps } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions, type StaffRoleTechAdminPermissionsEntityReference, type StaffRoleTechAdminPermissionsProps } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions, type StaffRoleUserPermissionsEntityReference, type StaffRoleUserPermissionsProps } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions, type StaffRoleViolationTicketPermissionsEntityReference, type StaffRoleViolationTicketPermissionsProps } from './staff-role-violation-ticket-permissions.ts'; export interface StaffRolePermissionsProps extends ValueObjectProps { @@ -13,15 +16,26 @@ export interface StaffRolePermissionsProps extends ValueObjectProps { readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsProps; readonly servicePermissions: StaffRoleServicePermissionsProps; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsProps; + readonly financePermissions: StaffRoleFinancePermissionsProps; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsProps; + readonly userPermissions: StaffRoleUserPermissionsProps; } export interface StaffRolePermissionsEntityReference - extends Readonly> { + extends Readonly< + Omit< + StaffRolePermissionsProps, + 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' + > + > { readonly communityPermissions: StaffRoleCommunityPermissionsEntityReference; readonly propertyPermissions: StaffRolePropertyPermissionsEntityReference; readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsEntityReference; readonly servicePermissions: StaffRoleServicePermissionsEntityReference; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsEntityReference; + readonly financePermissions: StaffRoleFinancePermissionsEntityReference; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsEntityReference; + readonly userPermissions: StaffRoleUserPermissionsEntityReference; } export class StaffRolePermissions extends ValueObject implements StaffRolePermissionsEntityReference { @@ -47,4 +61,13 @@ export class StaffRolePermissions extends ValueObject get violationTicketPermissions(): StaffRoleViolationTicketPermissions { return new StaffRoleViolationTicketPermissions(this.props.violationTicketPermissions, this.visa); } + get financePermissions(): StaffRoleFinancePermissions { + return new StaffRoleFinancePermissions(this.props.financePermissions, this.visa); + } + get techAdminPermissions(): StaffRoleTechAdminPermissions { + return new StaffRoleTechAdminPermissions(this.props.techAdminPermissions, this.visa); + } + get userPermissions(): StaffRoleUserPermissions { + return new StaffRoleUserPermissions(this.props.userPermissions, this.visa); + } } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts new file mode 100644 index 000000000..07bcae739 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-tech-admin-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleTechAdminPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleTechAdminPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageTechAdmin with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canManageTechAdmin to true', () => { + setWithoutPermission = () => { + entity.canManageTechAdmin = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewDatabaseExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewDatabaseExplorer to true', () => { + entity.canViewDatabaseExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewDatabaseExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewDatabaseExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewDatabaseExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewBlobExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewBlobExplorer to true', () => { + entity.canViewBlobExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewBlobExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewBlobExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewBlobExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewQueueDashboard with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewQueueDashboard to true', () => { + entity.canViewQueueDashboard = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Changing canViewQueueDashboard without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewQueueDashboard to true', () => { + setWithoutPermission = () => { + entity.canViewQueueDashboard = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canSendQueueMessages with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canSendQueueMessages to true', () => { + entity.canSendQueueMessages = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); + + Scenario('Changing canSendQueueMessages without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canSendQueueMessages to true', () => { + setWithoutPermission = () => { + entity.canSendQueueMessages = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Reading tech admin permission flags', ({ Given, Then, And }) => { + Given('a StaffRoleTechAdminPermissions entity with all permission flags set to true', () => { + props = makeProps({ + canManageTechAdmin: true, + canViewDatabaseExplorer: true, + canViewBlobExplorer: true, + canViewQueueDashboard: true, + canSendQueueMessages: true, + }); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + Then('canManageTechAdmin should be true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + And('canViewDatabaseExplorer should be true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + And('canViewBlobExplorer should be true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + And('canViewQueueDashboard should be true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + And('canSendQueueMessages should be true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts new file mode 100644 index 000000000..9d225e6c7 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts @@ -0,0 +1,70 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleTechAdminPermissionsSpec { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleTechAdminPermissionsProps extends StaffRoleTechAdminPermissionsSpec, ValueObjectProps {} +export interface StaffRoleTechAdminPermissionsEntityReference extends Readonly {} + +export class StaffRoleTechAdminPermissions extends ValueObject implements StaffRoleTechAdminPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleTechAdminPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageTechAdmin(): boolean { + return this.props.canManageTechAdmin; + } + set canManageTechAdmin(value: boolean) { + this.validateVisa(); + this.props.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.props.canViewDatabaseExplorer; + } + set canViewDatabaseExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.props.canViewBlobExplorer; + } + set canViewBlobExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.props.canViewQueueDashboard; + } + set canViewQueueDashboard(value: boolean) { + this.validateVisa(); + this.props.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.props.canSendQueueMessages; + } + set canSendQueueMessages(value: boolean) { + this.validateVisa(); + this.props.canSendQueueMessages = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts new file mode 100644 index 000000000..969d1e7ab --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-user-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageUsers: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleUserPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleUserPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleUserPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageUsers with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleUserPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I try to set canManageUsers to true', () => { + setWithoutPermission = () => { + entity.canManageUsers = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts new file mode 100644 index 000000000..358c7a7c4 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts @@ -0,0 +1,34 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleUserPermissionsSpec { + canManageUsers: boolean; +} + +export interface StaffRoleUserPermissionsProps extends StaffRoleUserPermissionsSpec, ValueObjectProps {} +export interface StaffRoleUserPermissionsEntityReference extends Readonly {} + +export class StaffRoleUserPermissions extends ValueObject implements StaffRoleUserPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleUserPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageUsers(): boolean { + return this.props.canManageUsers; + } + set canManageUsers(value: boolean) { + this.validateVisa(); + this.props.canManageUsers = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts index 02fb77d52..ff2cf93bc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts @@ -2,6 +2,11 @@ import type { Repository } from '@cellix/domain-seedwork/repository'; import type { StaffRole, StaffRoleProps } from './staff-role.ts'; export interface StaffRoleRepository extends Repository> { getNewInstance(name: string): Promise>; + getNewDefaultCaseManagerInstance(): Promise>; + getNewDefaultServiceLineOwnerInstance(): Promise>; + getNewDefaultFinanceInstance(): Promise>; + getNewDefaultTechAdminInstance(): Promise>; getById(id: string): Promise>; getByRoleName(roleName: string): Promise>; + getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise>; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts index fade23a8e..5b4c620fe 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts @@ -6,7 +6,7 @@ import { expect, vi } from 'vitest'; import { RoleDeletedReassignEvent } from '../../../events/types/role-deleted-reassign.ts'; import type { Passport } from '../../passport.ts'; import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from './staff-role.ts'; -import { StaffRolePermissions } from './staff-role-permissions.ts'; +import { StaffRolePermissions, type StaffRolePermissionsProps } from './staff-role-permissions.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -27,6 +27,7 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps id: 'role-1', roleName: 'Support', isDefault: false, + enterpriseAppRole: '', permissions: {} as StaffRolePermissions, roleType: 'staff-role', createdAt: new Date('2020-01-01T00:00:00Z'), @@ -36,6 +37,55 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps }; } +function makePermissionsProps(overrides: Partial = {}): StaffRolePermissionsProps { + return { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + }, + ...overrides, + }; +} + function getIntegrationEvent(events: readonly unknown[], eventClass: new (aggregateId: string) => T): T | undefined { return events.find((e) => e instanceof eventClass) as T | undefined; } @@ -284,4 +334,58 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(staffRole.schemaVersion).toBe('1.0.0'); }); }); + + // getDefaultRoleNames + Scenario('Getting default role names', ({ When, Then, And }) => { + let roleNames: string[]; + When('I call getDefaultRoleNames', () => { + roleNames = StaffRole.getDefaultRoleNames(); + }); + Then('the result should contain "Default.CaseManager"', () => { + expect(roleNames).toContain('Default.CaseManager'); + }); + And('the result should contain "Default.ServiceLineOwner"', () => { + expect(roleNames).toContain('Default.ServiceLineOwner'); + }); + And('the result should contain "Default.Finance"', () => { + expect(roleNames).toContain('Default.Finance'); + }); + And('the result should contain "Default.TechAdmin"', () => { + expect(roleNames).toContain('Default.TechAdmin'); + }); + And('the result should have exactly 4 names', () => { + expect(roleNames).toHaveLength(4); + }); + }); + + Scenario('Creating a default tech admin role', ({ When, Then, And }) => { + let techAdminRole: StaffRole; + When('I create a default tech admin staff role', () => { + techAdminRole = StaffRole.getNewDefaultTechAdminInstance( + makeBaseProps({ permissions: makePermissionsProps() }), + passport, + ); + }); + Then('the roleName should be "Default Tech Admin"', () => { + expect(techAdminRole.roleName).toBe('Default Tech Admin'); + }); + And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { + expect(techAdminRole.enterpriseAppRole).toBe('Staff.TechAdmin'); + }); + And('the tech admin role should allow managing communities', () => { + expect(techAdminRole.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('the tech admin role should allow managing staff roles and permissions', () => { + expect(techAdminRole.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('the tech admin role should allow managing finance', () => { + expect(techAdminRole.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('the tech admin role should allow managing tech admin', () => { + expect(techAdminRole.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + And('the tech admin role should allow managing users', () => { + expect(techAdminRole.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts index 49912c977..de28bea83 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts @@ -10,6 +10,7 @@ import { StaffRolePermissions, type StaffRolePermissionsEntityReference, type St export interface StaffRoleProps extends DomainEntityProps { roleName: string; isDefault: boolean; + enterpriseAppRole: string; readonly permissions: StaffRolePermissionsProps; readonly roleType: string | null; readonly createdAt: Date; @@ -37,6 +38,72 @@ export class StaffRole extends AggregateRoot(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Case Manager'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.CaseManager; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultServiceLineOwnerInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Service Line Owner'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.ServiceLineOwner; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultFinanceInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Finance'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.Finance; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = false; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = false; + role.isNew = false; + return role; + } + + public static getNewDefaultTechAdminInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Tech Admin'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.TechAdmin; + role.isDefault = true; + // Tech Admins are implicit managers of all areas + role.permissions.communityPermissions.canManageCommunities = true; + // Tech Admins should also be able to manage staff roles & permissions by default + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = true; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } public deleteAndReassignTo(roleRef: StaffRoleEntityReference) { if (this.isDefault) { throw new PermissionError('You cannot delete a default staff role'); @@ -60,6 +127,18 @@ export class StaffRole extends AggregateRoot permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set enterprise app role'); + } + this.props.enterpriseAppRole = new ValueObjects.EnterpriseAppRole(enterpriseAppRole).valueOf(); + } + get isDefault() { return this.props.isDefault; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts index 3a4a42e28..653a9d0f2 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts @@ -1,7 +1,16 @@ -import { VOString } from '@lucaspaganini/value-objects'; +import { VOString, VOSet } from '@lucaspaganini/value-objects'; export class RoleName extends VOString({ trim: true, maxLength: 50, minLength: 1, }) {} + +export const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +export class EnterpriseAppRole extends VOSet(Object.values(EnterpriseAppRoleNames)) {} \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts new file mode 100644 index 000000000..7375b9941 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts @@ -0,0 +1,42 @@ +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/index.ts'; +import type { UserDomainPermissions } from '../../../../contexts/user/user.domain-permissions.ts'; +import type { UserPassport } from '../../../../contexts/user/user.passport.ts'; +import type { UserVisa } from '../../../../contexts/user/user.visa.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserPassportBase } from '../../staff-user.passport-base.ts'; + +export class StaffUserUserPassport extends StaffUserPassportBase implements UserPassport { + forEndUser(_root: EndUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forStaffUser(root: StaffUserEntityReference): UserVisa { + const permissions = this.buildPermissions(root); + return { determineIf: (func) => func(permissions) }; + } + + forStaffRole(_root: StaffRoleEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forVendorUser(_root: VendorUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + private buildPermissions(root?: StaffUserEntityReference): UserDomainPermissions { + const canManageStaffRolesAndPermissions = this._user.role?.permissions.communityPermissions.canManageStaffRolesAndPermissions ?? false; + return { + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: canManageStaffRolesAndPermissions, + canManageVendorUsers: false, + isEditingOwnAccount: root !== undefined && root.externalId === this._user.externalId, + isSystemAccount: false, + }; + } +} diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature index 8145b32b4..172644ebb 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature @@ -20,4 +20,4 @@ Feature: StaffUserPassport Scenario: Accessing the user passport When I create a StaffUserPassport with valid staff user And I access the user property - Then an error should be thrown indicating the user passport is not available \ No newline at end of file + Then I should receive a StaffUserUserPassport instance \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts index 62a730e0a..6e5f74139 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts @@ -6,6 +6,7 @@ import type { CommunityEntityReference } from '../../../contexts/community/commu import type { StaffUserEntityReference } from '../../../contexts/user/staff-user/staff-user.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserCommunityVisa } from './contexts/staff-user.community.visa.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; import { StaffUserPassport } from './staff-user.passport.ts'; const test = { for: describeFeature }; @@ -85,15 +86,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the user passport', ({ When, And, Then }) => { - let getUserPassport: () => void; + let userPassport: unknown; When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); And('I access the user property', () => { - getUserPassport = () => passport.user; + userPassport = passport.user; }); - Then('an error should be thrown indicating the user passport is not available', () => { - expect(getUserPassport).toThrow('User passport is not available for StaffUserPassport'); + Then('I should receive a StaffUserUserPassport instance', () => { + expect(userPassport).toBeInstanceOf(StaffUserUserPassport); }); }); }); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts index 5d2715451..92a9a9369 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts @@ -2,15 +2,18 @@ import type { CasePassport } from '../../../contexts/case/case.passport.ts'; import type { CommunityPassport } from '../../../contexts/community/community.passport.ts'; import type { Passport } from '../../../contexts/passport.ts'; import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { UserPassport } from '../../../contexts/user/user.passport.ts'; import { StaffUserPassportBase } from '../staff-user.passport-base.ts'; import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; export class StaffUserPassport extends StaffUserPassportBase implements Passport { private _communityPassport: CommunityPassport | undefined; private _propertyPassport: PropertyPassport | undefined; private _casePassport: CasePassport | undefined; + private _userPassport: UserPassport | undefined; public get case(): CasePassport { if (!this._casePassport) { @@ -37,7 +40,10 @@ export class StaffUserPassport extends StaffUserPassportBase implements Passport throw new Error('Service passport is not available for StaffUserPassport'); } - public get user(): never { - throw new Error('User passport is not available for StaffUserPassport'); + public get user(): UserPassport { + if (!this._userPassport) { + this._userPassport = new StaffUserUserPassport(this._user); + } + return this._userPassport; } } diff --git a/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature b/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature new file mode 100644 index 000000000..92c7844d5 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/features/staff-user-management.feature @@ -0,0 +1,30 @@ +@staff-user @e2e +Feature: Staff User Management End-to-End Flow + As a staff user administrator + I want new staff users to be created with the correct default role + So that permissions stay consistent + + Background: + Given I am an authorized staff user administrator + And a staff user blueprint is prepared without an assigned role + + Scenario: Create a staff user + When I create the staff user + Then the staff user should be created successfully + And the staff user should have no default role assigned yet + + Scenario Outline: Assign the default role to a staff user + When I create the staff user + And I assign the default staff role "" + Then the assigned role name should be "" + And the assigned role enterprise app role should be "" + And the assigned role should be default + And the assigned role permissions should be communities , staff roles , finance , tech admin , users + And the staff user should expose the assigned role + + Examples: + | defaultRole | expectedRoleName | expectedEnterpriseAppRole | canManageCommunities | canManageStaffRolesAndPermissions | canManageFinance | canManageTechAdmin | canManageUsers | + | case manager | Default Case Manager | Staff.CaseManager | true | false | false | false | true | + | service line owner | Default Service Line Owner | Staff.ServiceLineOwner | true | false | false | false | true | + | finance | Default Finance | Staff.Finance | false | false | true | false | false | + | tech admin | Default Tech Admin | Staff.TechAdmin | true | true | true | true | true | diff --git a/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts b/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts index 35b27128d..aa11e6b56 100644 --- a/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts +++ b/packages/ocom/domain/tests/acceptance/screenplay/interactions/create-community.ts @@ -29,7 +29,7 @@ export class CreateCommunity extends Interaction { static withName(name: string): CreateCommunity { const communityData = { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts index 753f3ed04..02f8003d9 100644 --- a/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts +++ b/packages/ocom/domain/tests/acceptance/step-definitions/community-management.steps.ts @@ -33,7 +33,7 @@ class SerenityCommunityWorld { // Initialize with default valid data this.validCommunityData = { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, @@ -331,8 +331,8 @@ Then('the full name should be preserved', function (this: SerenityCommunityWorld this.syncResultsFromScreenplay(); assert.ok(this.createdCommunity, 'Community should have been created'); - assert.strictEqual(this.createdCommunity.name.length, this.communityName.length, 'Full name length should be preserved'); - assert.strictEqual(this.createdCommunity.name, this.communityName, 'Full name content should be preserved'); + assert.strictEqual(this.createdCommunity?.name.length, this.communityName.length, 'Full name length should be preserved'); + assert.strictEqual(this.createdCommunity?.name, this.communityName, 'Full name content should be preserved'); console.log(`✓ Full name preserved: ${this.communityName.length} characters`); }); diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts new file mode 100644 index 000000000..7db4ce15d --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/step-definitions/member-management.steps.ts @@ -0,0 +1,44 @@ +import { Before, Given, Then, When } from '@cucumber/cucumber'; +import assert from 'node:assert'; +import { Member } from '../../../src/domain/contexts/community/member/member.ts'; +import { MemberActivatedEvent } from '../../../src/domain/events/types/member-activated.ts'; +import { MemberRemovedEvent } from '../../../src/domain/events/types/member-removed.ts'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import type { MemberProps } from '../../../src/domain/contexts/community/member/member.ts'; +import { createMemberProps, createMockPassport } from '../support/member-test-utils.ts'; +let passport: Passport; +let member: Member; + +Before(() => { + passport = createMockPassport({ canManageMembers: true }); + member = undefined as unknown as Member; +}); + +Given('I am an authorized community administrator for member management', () => { + passport = createMockPassport({ canManageMembers: true }); + assert.ok(passport); +}); + +Given('a member exists with a pending account', () => { + member = new Member(createMemberProps('CREATED'), passport); + assert.ok(member); +}); + +When('I activate the member', () => { + member.requestActivateMember(); +}); + +Then('the member should be active', () => { + assert.strictEqual(member.isActiveMember, true); + assert.strictEqual(member.accounts[0]?.statusCode, 'ACCEPTED'); + assert.ok(member.getDomainEvents().some((event) => event instanceof MemberActivatedEvent)); +}); + +When('I remove the member', () => { + member.requestRemoveMember(); +}); + +Then('the member should be marked as removed', () => { + assert.strictEqual(member.isDeleted, true); + assert.ok(member.getDomainEvents().some((event) => event instanceof MemberRemovedEvent)); +}); diff --git a/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts b/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts new file mode 100644 index 000000000..bf31018f1 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/step-definitions/staff-user-management.steps.ts @@ -0,0 +1,111 @@ +import { Before, Given, Then, When } from '@cucumber/cucumber'; +import assert from 'node:assert'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import { StaffRole } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import type { StaffRoleProps } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import { StaffUser } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; +import { StaffUserCreatedEvent } from '../../../src/domain/events/types/staff-user-created.ts'; +import type { StaffUserProps } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; +import { createAuthorizingStaffRoleProps, createMockPassport, createStaffUserProps } from '../support/staff-user-test-utils.ts'; + +type DefaultRoleKey = 'case manager' | 'service line owner' | 'finance' | 'tech admin'; + +let passport: Passport; +let staffUser: StaffUser; +let assignedRole: StaffRole; + +Before(() => { + passport = createMockPassport({ canManageStaffRolesAndPermissions: true }); + staffUser = undefined as unknown as StaffUser; + assignedRole = undefined as unknown as StaffRole; +}); + +Given('I am an authorized staff user administrator', () => { + passport = createMockPassport({ canManageStaffRolesAndPermissions: true }); + assert.ok(passport); +}); + +Given('a staff user blueprint is prepared without an assigned role', () => { + const props = createStaffUserProps(); + props.setRoleRef(undefined); + assert.strictEqual(props.role, undefined); +}); + +When('I create the staff user', () => { + staffUser = StaffUser.getNewUser(createStaffUserProps(), passport, '123e4567-e89b-12d3-a456-426614174000', 'Alice', 'Smith', 'alice@cellix.com'); +}); + +Then('the staff user should be created successfully', () => { + assert.ok(staffUser); + assert.strictEqual(staffUser.firstName, 'Alice'); + assert.strictEqual(staffUser.lastName, 'Smith'); + assert.strictEqual(staffUser.email, 'alice@cellix.com'); + assert.strictEqual(staffUser.displayName, 'Alice Smith'); + assert.ok(staffUser.getIntegrationEvents().some((event) => event instanceof StaffUserCreatedEvent)); +}); + +Then('the staff user should have no default role assigned yet', () => { + assert.strictEqual(staffUser.role, undefined); +}); + +When('I assign the default staff role {string}', (defaultRole: DefaultRoleKey) => { + const roleProps = createAuthorizingStaffRoleProps(); + let role: StaffRole | undefined; + + switch (defaultRole) { + case 'case manager': + role = StaffRole.getNewDefaultCaseManagerInstance(roleProps, passport); + break; + case 'service line owner': + role = StaffRole.getNewDefaultServiceLineOwnerInstance(roleProps, passport); + break; + case 'finance': + role = StaffRole.getNewDefaultFinanceInstance(roleProps, passport); + break; + case 'tech admin': + role = StaffRole.getNewDefaultTechAdminInstance(roleProps, passport); + break; + default: + throw new Error(`Unsupported default role: ${defaultRole}`); + } + + assert.ok(role); + assignedRole = role; + staffUser.role = role; +}); + +Then('the assigned role name should be {string}', (expectedRoleName: string) => { + assert.strictEqual(assignedRole.roleName, expectedRoleName); + assert.strictEqual(staffUser.role?.roleName, expectedRoleName); +}); + +Then('the assigned role enterprise app role should be {string}', (expectedEnterpriseAppRole: string) => { + assert.strictEqual(assignedRole.enterpriseAppRole, expectedEnterpriseAppRole); + assert.strictEqual(staffUser.role?.enterpriseAppRole, expectedEnterpriseAppRole); +}); + +Then('the assigned role should be default', () => { + assert.strictEqual(assignedRole.isDefault, true); +}); + +Then( + 'the assigned role permissions should be communities {word}, staff roles {word}, finance {word}, tech admin {word}, users {word}', + ( + canManageCommunities: string, + canManageStaffRolesAndPermissions: string, + canManageFinance: string, + canManageTechAdmin: string, + canManageUsers: string, + ) => { + assert.strictEqual(assignedRole.permissions.communityPermissions.canManageCommunities, canManageCommunities === 'true'); + assert.strictEqual(assignedRole.permissions.communityPermissions.canManageStaffRolesAndPermissions, canManageStaffRolesAndPermissions === 'true'); + assert.strictEqual(assignedRole.permissions.financePermissions.canManageFinance, canManageFinance === 'true'); + assert.strictEqual(assignedRole.permissions.techAdminPermissions.canManageTechAdmin, canManageTechAdmin === 'true'); + assert.strictEqual(assignedRole.permissions.userPermissions.canManageUsers, canManageUsers === 'true'); + }, +); + +Then('the staff user should expose the assigned role', () => { + assert.ok(staffUser.role); + assert.strictEqual(staffUser.role?.roleName, assignedRole.roleName); +}); diff --git a/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts b/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts new file mode 100644 index 000000000..211ffa49c --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/support/member-test-utils.ts @@ -0,0 +1,117 @@ +import type { CommunityEntityReference } from '../../../src/domain/contexts/community/community/community.ts'; +import type { EndUserRoleEntityReference } from '../../../src/domain/contexts/community/role/end-user-role/end-user-role.ts'; +import type { MemberAccountProps } from '../../../src/domain/contexts/community/member/member-account.ts'; +import type { MemberCustomViewProps } from '../../../src/domain/contexts/community/member/member-custom-view.ts'; +import type { MemberProfileProps } from '../../../src/domain/contexts/community/member/member-profile.ts'; +import type { MemberProps } from '../../../src/domain/contexts/community/member/member.ts'; +import type { Passport } from '../../../src/domain/contexts/passport.ts'; + +type MemberPermissions = { + canManageMembers?: boolean; + isSystemAccount?: boolean; +}; + +function createVisa(permissions: MemberPermissions) { + return { + determineIf: (fn: (value: { canManageMembers: boolean; isSystemAccount: boolean }) => boolean) => + fn({ + canManageMembers: permissions.canManageMembers ?? true, + isSystemAccount: permissions.isSystemAccount ?? false, + }), + }; +} + +function createPropArray(items: T[], createNewItem: () => T) { + return { + get items() { + return items; + }, + addItem: (item: T) => { + items.push(item); + }, + getNewItem: () => createNewItem(), + removeItem: (item: T) => { + const index = items.findIndex(({ id }) => id === item.id); + if (index >= 0) { + items.splice(index, 1); + } + }, + removeAll: () => { + items.splice(0, items.length); + }, + }; +} + +export function createMockPassport(permissions: MemberPermissions = {}): Passport { + const visa = createVisa(permissions); + + return { + community: { + forCommunity: () => visa, + }, + } as unknown as Passport; +} + +export function createMemberProps(accountStatusCode: string = 'CREATED'): MemberProps { + const accountProps: MemberAccountProps = { + id: 'account-1', + firstName: 'Alice', + lastName: 'Smith', + user: { id: 'user-1' } as never, + statusCode: accountStatusCode, + createdBy: { id: 'creator-1' } as never, + }; + + const customViewProps: MemberCustomViewProps = { + id: 'custom-view-1', + name: 'Default View', + type: 'TABLE', + filters: [], + sortOrder: 'ASC', + columnsToDisplay: [], + }; + + const profileProps: MemberProfileProps = { + name: 'Test Member', + email: 'alice@example.com', + bio: 'Test bio', + avatarDocumentId: 'avatar-1', + interests: [], + showInterests: true, + showEmail: true, + showProfile: true, + showLocation: false, + showProperties: false, + }; + + return { + id: 'member-1', + memberName: 'Test Member', + cybersourceCustomerId: 'customer-1', + communityId: 'community-1', + community: { id: 'community-1' } as CommunityEntityReference, + loadCommunity: async () => ({ id: 'community-1' } as CommunityEntityReference), + accounts: createPropArray([accountProps], () => ({ + id: 'account-new', + firstName: 'New', + lastName: 'Member', + user: { id: 'user-new' } as never, + statusCode: 'CREATED', + createdBy: { id: 'creator-new' } as never, + })), + role: { id: 'role-1' } as EndUserRoleEntityReference, + loadRole: async () => ({ id: 'role-1' } as EndUserRoleEntityReference), + customViews: createPropArray([customViewProps], () => ({ + id: 'custom-view-new', + name: 'New View', + type: 'TABLE', + filters: [], + sortOrder: 'ASC', + columnsToDisplay: [], + })), + profile: profileProps, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} diff --git a/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts b/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts new file mode 100644 index 000000000..938f1c417 --- /dev/null +++ b/packages/ocom/domain/tests/acceptance/support/staff-user-test-utils.ts @@ -0,0 +1,118 @@ +import type { Passport } from '../../../src/domain/contexts/passport.ts'; +import type { StaffRoleProps } from '../../../src/domain/contexts/user/staff-role/staff-role.ts'; +import type { StaffUserProps } from '../../../src/domain/contexts/user/staff-user/staff-user.ts'; + +type StaffUserPermissions = { + canManageStaffRolesAndPermissions?: boolean; + isSystemAccount?: boolean; +}; + +function createVisa(permissions: StaffUserPermissions) { + return { + determineIf: (fn: (value: { canManageEndUsers: boolean; canManageStaffRolesAndPermissions: boolean; canManageStaffUsers: boolean; canManageVendorUsers: boolean; isEditingOwnAccount: boolean; isSystemAccount: boolean }) => boolean) => + fn({ + canManageEndUsers: false, + canManageStaffRolesAndPermissions: permissions.canManageStaffRolesAndPermissions ?? true, + canManageStaffUsers: permissions.canManageStaffRolesAndPermissions ?? true, + canManageVendorUsers: false, + isEditingOwnAccount: false, + isSystemAccount: permissions.isSystemAccount ?? false, + }), + }; +} + +export function createMockPassport(permissions: StaffUserPermissions = {}): Passport { + const visa = createVisa(permissions); + + return { + user: { + forEndUser: () => visa, + forStaffUser: () => visa, + forStaffRole: () => visa, + forVendorUser: () => visa, + }, + } as unknown as Passport; +} + +export function createAuthorizingStaffRoleProps(): StaffRoleProps { + return { + id: 'authorizing-role-1', + roleName: 'Authorizing Role', + isDefault: false, + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + }, + }, + roleType: 'staff-role', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} + +export function createStaffUserProps(): StaffUserProps { + let roleRef: StaffRoleProps | undefined; + + return { + id: 'staff-user-1', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@cellix.com', + displayName: 'Alice Smith', + externalId: '123e4567-e89b-12d3-a456-426614174000', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return roleRef; + }, + setRoleRef: (role) => { + roleRef = role; + }, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + }; +} diff --git a/packages/ocom/domain/tests/integration/community-management.integration.test.ts b/packages/ocom/domain/tests/integration/community-management.integration.test.ts index 29e581ac4..e608dd57c 100644 --- a/packages/ocom/domain/tests/integration/community-management.integration.test.ts +++ b/packages/ocom/domain/tests/integration/community-management.integration.test.ts @@ -13,7 +13,7 @@ describe('Community Management - Cucumber Integration Tests', () => { const createValidCommunityData = (): CommunityProps => { return { id: '12345', - name: '', + name: 'Placeholder Community', domain: '', whiteLabelDomain: null, handle: null, diff --git a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts index 8fc71a151..5992cc548 100644 --- a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts +++ b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts @@ -1,5 +1,4 @@ import { mergeResolvers } from '@graphql-tools/merge'; -import endUserRoleResolvers from '../types/end-user-role.resolvers.ts'; import type { Resolvers } from './generated.ts'; import { ocomGraphqlPermissions, ocomGraphqlResolvers } from './resolver-manifest.generated.ts'; @@ -7,5 +6,5 @@ function mergeResolverModules(modules: Resolvers[]): Resolvers { return (modules.length === 0 ? {} : mergeResolvers(modules)) as Resolvers; } -export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers, endUserRoleResolvers]); +export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers]); export const permissions: Resolvers = mergeResolverModules(ocomGraphqlPermissions); diff --git a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature new file mode 100644 index 000000000..265800347 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -0,0 +1,22 @@ +Feature: Staff User Resolvers + + As an API consumer + I want to query and create staff user entities + So that I can retrieve a staff user or ensure one exists via the GraphQL API + + Scenario: Querying the current staff user and creating if not exists + Given a user with a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the JWT claims + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with AAD roles + Given a user with a verifiedJwt that includes AAD roles in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the AAD roles + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with no JWT + Given a user without a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/graphql/src/schema/types/staff-user.graphql b/packages/ocom/graphql/src/schema/types/staff-user.graphql new file mode 100644 index 000000000..1def99be8 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.graphql @@ -0,0 +1,66 @@ +type StaffRoleCommunityPermissions { + canManageCommunities: Boolean! + canManageStaffRolesAndPermissions: Boolean! + canManageAllCommunities: Boolean! + canDeleteCommunities: Boolean! + canChangeCommunityOwner: Boolean! + canReIndexSearchCollections: Boolean! +} + +type StaffRoleFinancePermissions { + canManageFinance: Boolean! + canViewGLBatchSummaries: Boolean! + canViewFinanceConfigs: Boolean! + canCreateFinanceConfigs: Boolean! +} + +type StaffRoleTechAdminPermissions { + canManageTechAdmin: Boolean! + canViewDatabaseExplorer: Boolean! + canViewBlobExplorer: Boolean! + canViewQueueDashboard: Boolean! + canSendQueueMessages: Boolean! +} + +type StaffRoleUserPermissions { + canManageUsers: Boolean! +} + +type StaffRolePermissions { + communityPermissions: StaffRoleCommunityPermissions! + financePermissions: StaffRoleFinancePermissions! + techAdminPermissions: StaffRoleTechAdminPermissions! + userPermissions: StaffRoleUserPermissions! +} + +type StaffRole implements MongoBase { + roleName: String! + isDefault: Boolean! + roleType: String + permissions: StaffRolePermissions! + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +type StaffUser implements MongoBase { + externalId: String! + firstName: String! + lastName: String! + email: String! + displayName: String! + accessBlocked: Boolean! + tags: [String!]! + role: StaffRole + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +extend type Query { + currentStaffUserAndCreateIfNotExists: StaffUser! +} diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts new file mode 100644 index 000000000..66b97abcf --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.resolvers.feature')); + +type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; + +function createMockStaffUser(overrides: Partial = {}): StaffUserEntity { + return { + id: 'mock-staff-user-id', + externalId: 'mock-external-id', + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + email: 'jane@example.com', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffUserEntity; +} + +function makeMockInfo(fieldName: string): GraphQLResolveInfo { + const mockFieldNode: FieldNode = { + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: fieldName }, + }; + return { + fieldName, + fieldNodes: [mockFieldNode], + returnType: {} as GraphQLObjectType, + parentType: {} as GraphQLObjectType, + path: { key: fieldName, prev: undefined, typename: undefined }, + schema: {} as GraphQLSchema, + fragments: {}, + rootValue: {}, + operation: {} as OperationDefinitionNode, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +function makeMockGraphContext(overrides: Partial = {}): GraphContext { + return { + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'default-user-sub', + given_name: 'Jane', + family_name: 'Smith', + email: 'jane@example.com', + roles: [], + }, + }, + ...overrides.applicationServices, + }, + ...overrides, + } as unknown as GraphContext; +} + +type QueryResolver = (parent: object, args: Record, context: GraphContext, info: GraphQLResolveInfo) => Promise; + +const callCurrentStaffUserQuery = (context: GraphContext) => (staffUserResolvers.Query?.currentStaffUserAndCreateIfNotExists as unknown as QueryResolver)({}, {}, context, makeMockInfo('currentStaffUserAndCreateIfNotExists')); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let result: StaffUserEntity | null; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + vi.clearAllMocks(); + result = null; + }); + + Scenario('Querying the current staff user and creating if not exists', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + + Given('a user with a verifiedJwt in their context', () => { + // Already set up in BeforeEachScenario with default jwt + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the JWT claims', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'default-user-sub', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + aadRoles: [], + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with AAD roles', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + const aadRoles = ['Staff.CaseManager', 'Staff.Finance']; + + Given('a user with a verifiedJwt that includes AAD roles in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'roles-user-sub', + given_name: 'Bob', + family_name: 'Jones', + email: 'bob@example.com', + roles: aadRoles, + }, + }, + } as unknown as GraphContext['applicationServices'], + }); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the AAD roles', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'roles-user-sub', + firstName: 'Bob', + lastName: 'Jones', + email: 'bob@example.com', + aadRoles, + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with no JWT', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + await expect(callCurrentStaffUserQuery(context)).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Already asserted in When + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts new file mode 100644 index 000000000..38be5afa1 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts @@ -0,0 +1,24 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import type { Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; + +const staffUser: Resolvers = { + Query: { + currentStaffUserAndCreateIfNotExists: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw new Error('Unauthorized'); + } + const result = await context.applicationServices.User.StaffUser.createIfNotExists({ + externalId: jwt.sub, + firstName: jwt.given_name ?? '', + lastName: jwt.family_name ?? '', + email: jwt.email ?? '', + aadRoles: jwt.roles ?? [], + }); + return result; + }, + }, +}; + +export default staffUser; diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts index 23ca925d2..8ca4d7dc5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts @@ -121,7 +121,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting message when document message is undefined', ({ Given, When, Then }) => { Given('a MemberInvitationDomainAdapter for a document with no message', () => { const docWithoutMessage = makeMemberInvitationDoc(); - // biome-ignore lint/performance/noDelete: needed to test undefined message scenario delete (docWithoutMessage as unknown as Record)['message']; doc = docWithoutMessage; adapter = new MemberInvitationDomainAdapter(doc); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature index 7b6f611eb..b9f141636 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature @@ -13,6 +13,26 @@ Feature: StaffRoleDomainAdapter When I set the roleName property to "Supervisor" Then the document's roleName should be "Supervisor" + Scenario: Setting the roleName updates the enterpriseAppRole + Given a StaffRoleDomainAdapter for the document + When I set the roleName property to "Supervisor" + Then the document's enterpriseAppRole should be "Supervisor" + + Scenario: Getting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document with enterpriseAppRole "Staff.Manager" + When I get the enterpriseAppRole property + Then it should return "Staff.Manager" + + Scenario: Getting the enterpriseAppRole property when missing + Given a StaffRoleDomainAdapter for the document with no enterpriseAppRole + When I get the enterpriseAppRole property + Then it should return "" + + Scenario: Setting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document + When I set the enterpriseAppRole property to "Staff.Supervisor" + Then the document's enterpriseAppRole should be "Staff.Supervisor" + Scenario: Getting the isDefault property Given a StaffRoleDomainAdapter for the document When I get the isDefault property @@ -151,4 +171,149 @@ Feature: StaffRoleDomainAdapter And the canAssignTickets property should return false And the canWorkOnTickets property should return false When I set the canCreateTickets property to true - Then the violationTicketPermissions' canCreateTickets should be true \ No newline at end of file + Then the violationTicketPermissions' canCreateTickets should be true + + Scenario: Getting and setting canManageCommunities from communityPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the communityPermissions property + And I get the canManageCommunities property + Then it should return false + When I set the canManageCommunities property to true + Then the communityPermissions' canManageCommunities should be true + + Scenario: Getting financePermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + + Scenario: Getting and setting canManageFinance from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canManageFinance property should return false + When I set the canManageFinance property to true + Then the financePermissions' canManageFinance should be true + + Scenario: Getting and setting canViewGLBatchSummaries from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewGLBatchSummaries property should return false + When I set the canViewGLBatchSummaries property to true + Then the financePermissions' canViewGLBatchSummaries should be true + + Scenario: Getting and setting canViewFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewFinanceConfigs property should return false + When I set the canViewFinanceConfigs property to true + Then the financePermissions' canViewFinanceConfigs should be true + + Scenario: Getting and setting canCreateFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canCreateFinanceConfigs property should return false + When I set the canCreateFinanceConfigs property to true + Then the financePermissions' canCreateFinanceConfigs should be true + + Scenario: Getting techAdminPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + + Scenario: Getting and setting canManageTechAdmin from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canManageTechAdmin property should return false + When I set the canManageTechAdmin property to true + Then the techAdminPermissions' canManageTechAdmin should be true + + Scenario: Getting and setting canViewDatabaseExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewDatabaseExplorer property should return false + When I set the canViewDatabaseExplorer property to true + Then the techAdminPermissions' canViewDatabaseExplorer should be true + + Scenario: Getting and setting canViewBlobExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewBlobExplorer property should return false + When I set the canViewBlobExplorer property to true + Then the techAdminPermissions' canViewBlobExplorer should be true + + Scenario: Getting and setting canViewQueueDashboard from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewQueueDashboard property should return false + When I set the canViewQueueDashboard property to true + Then the techAdminPermissions' canViewQueueDashboard should be true + + Scenario: Getting and setting canSendQueueMessages from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canSendQueueMessages property should return false + When I set the canSendQueueMessages property to true + Then the techAdminPermissions' canSendQueueMessages should be true + + Scenario: Getting userPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + + Scenario: Getting and setting canManageUsers from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canManageUsers property should return false + When I set the canManageUsers property to true + Then the userPermissions' canManageUsers should be true + + Scenario: Lazy-initialising permissions when document has no permissions object + Given a StaffRoleDomainAdapter wrapping a document with no permissions object + When I get the permissions property + Then it should return a StaffRolePermissionsAdapter instance + + Scenario: Lazy-initialising communityPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document + When I get the permissions property + And I get the communityPermissions property + Then it should return a StaffRoleCommunityPermissionsAdapter instance + And canManageCommunities should default to false + + Scenario: Lazy-initialising financePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + And canManageFinance should default to false + + Scenario: Lazy-initialising techAdminPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + And canManageTechAdmin should default to false + + Scenario: Lazy-initialising userPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + And canManageUsers should default to false + + Scenario: Getting roleType returns null when document roleType is undefined + Given a StaffRoleDomainAdapter wrapping a document with no roleType + When I get the roleType property + Then it should return null \ No newline at end of file diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature index 042b8f64a..1874bac51 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature @@ -26,6 +26,16 @@ Feature: StaffRoleRepository When I call getByRoleName with "nonexistent-role" Then an error should be thrown indicating "StaffRole with roleName nonexistent-role not found" + Scenario: Getting a default staff role by enterpriseAppRole + Given a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager" + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then I should receive a StaffRole domain object + And the domain object's isDefault should be true + + Scenario: Getting a default staff role by enterpriseAppRole that does not exist + When I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole" + Then an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found" + Scenario: Creating a new staff role instance When I call getNewInstance with name "Supervisor" Then I should receive a new StaffRole domain object diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts index 8f7abbf56..a4566488b 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts @@ -15,6 +15,9 @@ import { StaffRoleServicePermissionsAdapter, StaffRoleServiceTicketPermissionsAdapter, StaffRoleViolationTicketPermissionsAdapter, + StaffRoleFinancePermissionsAdapter, + StaffRoleTechAdminPermissionsAdapter, + StaffRoleUserPermissionsAdapter, } from './staff-role.domain-adapter.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -111,6 +114,57 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); }); + Scenario('Setting the roleName updates the enterpriseAppRole', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the roleName property to "Supervisor"', () => { + adapter.roleName = 'Supervisor'; + }); + Then('the document\'s enterpriseAppRole should be "Supervisor"', () => { + expect(doc.enterpriseAppRole).toBe('Supervisor'); + }); + }); + + Scenario('Getting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document with enterpriseAppRole "Staff.Manager"', () => { + doc = makeStaffRoleDoc({ enterpriseAppRole: 'Staff.Manager' }); + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return "Staff.Manager"', () => { + expect(result).toBe('Staff.Manager'); + }); + }); + + Scenario('Getting the enterpriseAppRole property when missing', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document with no enterpriseAppRole', () => { + doc = makeStaffRoleDoc(); + (doc as unknown as Record)['enterpriseAppRole'] = undefined; + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return ""', () => { + expect(result).toBe(''); + }); + }); + + Scenario('Setting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the enterpriseAppRole property to "Staff.Supervisor"', () => { + adapter.enterpriseAppRole = 'Staff.Supervisor'; + }); + Then('the document\'s enterpriseAppRole should be "Staff.Supervisor"', () => { + expect(doc.enterpriseAppRole).toBe('Staff.Supervisor'); + }); + }); + Scenario('Getting the isDefault property', ({ Given, When, Then }) => { Given('a StaffRoleDomainAdapter for the document', () => { adapter = new StaffRoleDomainAdapter(doc); @@ -498,6 +552,443 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => expect(doc.permissions?.violationTicketPermissions?.canCreateTickets).toBe(true); }); }); + + // ─── canManageCommunities ───────────────────────────────────────────────── + + Scenario('Getting and setting canManageCommunities from communityPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let communityPermissions: StaffRoleCommunityPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + communityPermissions = permissions.communityPermissions as StaffRoleCommunityPermissionsAdapter; + }); + And('I get the canManageCommunities property', () => { + result = communityPermissions.canManageCommunities; + }); + Then('it should return false', () => { + expect(result).toBe(false); + }); + When('I set the canManageCommunities property to true', () => { + communityPermissions.canManageCommunities = true; + }); + Then("the communityPermissions' canManageCommunities should be true", () => { + expect(doc.permissions?.communityPermissions?.canManageCommunities).toBe(true); + }); + }); + + // ─── financePermissions ─────────────────────────────────────────────────── + + Scenario('Getting financePermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageFinance from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canManageFinance property should return false', () => { + expect(financePermissions.canManageFinance).toBe(false); + }); + When('I set the canManageFinance property to true', () => { + financePermissions.canManageFinance = true; + }); + Then("the financePermissions' canManageFinance should be true", () => { + expect(doc.permissions?.financePermissions?.canManageFinance).toBe(true); + }); + }); + + Scenario('Getting and setting canViewGLBatchSummaries from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewGLBatchSummaries property should return false', () => { + expect(financePermissions.canViewGLBatchSummaries).toBe(false); + }); + When('I set the canViewGLBatchSummaries property to true', () => { + financePermissions.canViewGLBatchSummaries = true; + }); + Then("the financePermissions' canViewGLBatchSummaries should be true", () => { + expect(doc.permissions?.financePermissions?.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Getting and setting canViewFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewFinanceConfigs property should return false', () => { + expect(financePermissions.canViewFinanceConfigs).toBe(false); + }); + When('I set the canViewFinanceConfigs property to true', () => { + financePermissions.canViewFinanceConfigs = true; + }); + Then("the financePermissions' canViewFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Getting and setting canCreateFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canCreateFinanceConfigs property should return false', () => { + expect(financePermissions.canCreateFinanceConfigs).toBe(false); + }); + When('I set the canCreateFinanceConfigs property to true', () => { + financePermissions.canCreateFinanceConfigs = true; + }); + Then("the financePermissions' canCreateFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canCreateFinanceConfigs).toBe(true); + }); + }); + + // ─── techAdminPermissions ───────────────────────────────────────────────── + + Scenario('Getting techAdminPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageTechAdmin from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canManageTechAdmin property should return false', () => { + expect(techAdminPermissions.canManageTechAdmin).toBe(false); + }); + When('I set the canManageTechAdmin property to true', () => { + techAdminPermissions.canManageTechAdmin = true; + }); + Then("the techAdminPermissions' canManageTechAdmin should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Getting and setting canViewDatabaseExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewDatabaseExplorer property should return false', () => { + expect(techAdminPermissions.canViewDatabaseExplorer).toBe(false); + }); + When('I set the canViewDatabaseExplorer property to true', () => { + techAdminPermissions.canViewDatabaseExplorer = true; + }); + Then("the techAdminPermissions' canViewDatabaseExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewBlobExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewBlobExplorer property should return false', () => { + expect(techAdminPermissions.canViewBlobExplorer).toBe(false); + }); + When('I set the canViewBlobExplorer property to true', () => { + techAdminPermissions.canViewBlobExplorer = true; + }); + Then("the techAdminPermissions' canViewBlobExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewQueueDashboard from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewQueueDashboard property should return false', () => { + expect(techAdminPermissions.canViewQueueDashboard).toBe(false); + }); + When('I set the canViewQueueDashboard property to true', () => { + techAdminPermissions.canViewQueueDashboard = true; + }); + Then("the techAdminPermissions' canViewQueueDashboard should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Getting and setting canSendQueueMessages from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canSendQueueMessages property should return false', () => { + expect(techAdminPermissions.canSendQueueMessages).toBe(false); + }); + When('I set the canSendQueueMessages property to true', () => { + techAdminPermissions.canSendQueueMessages = true; + }); + Then("the techAdminPermissions' canSendQueueMessages should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canSendQueueMessages).toBe(true); + }); + }); + + // ─── userPermissions ────────────────────────────────────────────────────── + + Scenario('Getting userPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageUsers from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canManageUsers property should return false', () => { + expect(userPermissions.canManageUsers).toBe(false); + }); + When('I set the canManageUsers property to true', () => { + userPermissions.canManageUsers = true; + }); + Then("the userPermissions' canManageUsers should be true", () => { + expect(doc.permissions?.userPermissions?.canManageUsers).toBe(true); + }); + }); + + // ─── Lazy-init paths ────────────────────────────────────────────────────── + + Scenario('Lazy-initialising permissions when document has no permissions object', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no permissions object', () => { + const docWithoutPermissions = makeStaffRoleDoc(); + docWithoutPermissions.set = vi.fn().mockImplementation((key: string, value: unknown) => { + (docWithoutPermissions as unknown as Record)[key] = value; + }); + (docWithoutPermissions as unknown as Record)['permissions'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithoutPermissions); + }); + When('I get the permissions property', () => { + result = adapter.permissions; + }); + Then('it should return a StaffRolePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePermissionsAdapter); + }); + }); + + Scenario('Lazy-initialising communityPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['communityPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + result = permissions.communityPermissions; + }); + Then('it should return a StaffRoleCommunityPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleCommunityPermissionsAdapter); + }); + And('canManageCommunities should default to false', () => { + expect((result as StaffRoleCommunityPermissionsAdapter).canManageCommunities).toBe(false); + }); + }); + + Scenario('Lazy-initialising financePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['financePermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + And('canManageFinance should default to false', () => { + expect((result as StaffRoleFinancePermissionsAdapter).canManageFinance).toBe(false); + }); + }); + + Scenario('Lazy-initialising techAdminPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['techAdminPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + And('canManageTechAdmin should default to false', () => { + expect((result as StaffRoleTechAdminPermissionsAdapter).canManageTechAdmin).toBe(false); + }); + }); + + Scenario('Lazy-initialising userPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['userPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + And('canManageUsers should default to false', () => { + expect((result as StaffRoleUserPermissionsAdapter).canManageUsers).toBe(false); + }); + }); + + Scenario('Getting roleType returns null when document roleType is undefined', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no roleType', () => { + const docWithout = makeStaffRoleDoc(); + (docWithout as unknown as Record)['roleType'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the roleType property', () => { + result = adapter.roleType; + }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); }); test.for(typeConverterFeature, ({ Scenario, Background, BeforeEachScenario }) => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts index 751577717..7bbf2c918 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts @@ -1,15 +1,17 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - -import { Domain } from '@ocom/domain'; import type { StaffRole, StaffRoleCommunityPermissions, + StaffRoleFinancePermissions, StaffRolePermissions, StaffRolePropertyPermissions, StaffRoleServicePermissions, StaffRoleServiceTicketPermissions, + StaffRoleTechAdminPermissions, + StaffRoleUserPermissions, StaffRoleViolationTicketPermissions, } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { Domain } from '@ocom/domain'; export class StaffRoleConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { @@ -24,6 +26,15 @@ export class StaffRoleDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt set roleName(roleName: string) { this.doc.roleName = roleName; + this.doc.enterpriseAppRole = roleName; + } + + get enterpriseAppRole(): string { + return this.doc.enterpriseAppRole ?? ''; + } + + set enterpriseAppRole(enterpriseAppRole: string) { + this.doc.enterpriseAppRole = enterpriseAppRole; } get isDefault(): boolean { @@ -56,6 +67,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo get communityPermissions(): Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { if (!this.doc.communityPermissions) { this.doc.communityPermissions = { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -91,6 +103,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } @@ -103,11 +116,46 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } return new StaffRoleViolationTicketPermissionsAdapter(this.doc.violationTicketPermissions); } + + get financePermissions(): Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + if (!this.doc.financePermissions) { + this.doc.financePermissions = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + } + return new StaffRoleFinancePermissionsAdapter(this.doc.financePermissions); + } + + get techAdminPermissions(): Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + if (!this.doc.techAdminPermissions) { + this.doc.techAdminPermissions = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + } + return new StaffRoleTechAdminPermissionsAdapter(this.doc.techAdminPermissions); + } + + get userPermissions(): Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + if (!this.doc.userPermissions) { + this.doc.userPermissions = { + canManageUsers: false, + }; + } + return new StaffRoleUserPermissionsAdapter(this.doc.userPermissions); + } } export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { @@ -125,6 +173,13 @@ export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.Use return this.doc.id?.toString(); } + get canManageCommunities(): boolean { + return this.ensureValue(this.doc.canManageCommunities); + } + set canManageCommunities(value: boolean) { + this.doc.canManageCommunities = value; + } + get canManageStaffRolesAndPermissions(): boolean { return this.ensureValue(this.doc.canManageStaffRolesAndPermissions); } @@ -269,3 +324,121 @@ export class StaffRoleViolationTicketPermissionsAdapter implements Domain.Contex this.doc.canWorkOnTickets = value; } } + +export class StaffRoleFinancePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + private readonly doc: StaffRoleFinancePermissions; + + constructor(permissions: StaffRoleFinancePermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageFinance(): boolean { + return this.ensureValue(this.doc.canManageFinance); + } + set canManageFinance(value: boolean) { + this.doc.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.ensureValue(this.doc.canViewGLBatchSummaries); + } + set canViewGLBatchSummaries(value: boolean) { + this.doc.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canViewFinanceConfigs); + } + set canViewFinanceConfigs(value: boolean) { + this.doc.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canCreateFinanceConfigs); + } + set canCreateFinanceConfigs(value: boolean) { + this.doc.canCreateFinanceConfigs = value; + } +} + +export class StaffRoleTechAdminPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + private readonly doc: StaffRoleTechAdminPermissions; + + constructor(permissions: StaffRoleTechAdminPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageTechAdmin(): boolean { + return this.ensureValue(this.doc.canManageTechAdmin); + } + set canManageTechAdmin(value: boolean) { + this.doc.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.ensureValue(this.doc.canViewDatabaseExplorer); + } + set canViewDatabaseExplorer(value: boolean) { + this.doc.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.ensureValue(this.doc.canViewBlobExplorer); + } + set canViewBlobExplorer(value: boolean) { + this.doc.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.ensureValue(this.doc.canViewQueueDashboard); + } + set canViewQueueDashboard(value: boolean) { + this.doc.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.ensureValue(this.doc.canSendQueueMessages); + } + set canSendQueueMessages(value: boolean) { + this.doc.canSendQueueMessages = value; + } +} + +export class StaffRoleUserPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + private readonly doc: StaffRoleUserPermissions; + + constructor(permissions: StaffRoleUserPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageUsers(): boolean { + return this.ensureValue(this.doc.canManageUsers); + } + set canManageUsers(value: boolean) { + this.doc.canManageUsers = value; + } +} diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts index a68364db6..1940529fc 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts @@ -17,6 +17,7 @@ function makeStaffRoleDoc(overrides: Partial = {}) { const base = { _id: 'role-1', roleName: 'Manager', + enterpriseAppRole: 'Staff.CaseManager', isDefault: false, roleType: 'staff', permissions: { @@ -84,10 +85,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(async () => (id === String(staffRoleDoc._id) ? staffRoleDoc : null)), + exec: vi.fn(() => Promise.resolve(id === String(staffRoleDoc._id) ? staffRoleDoc : null)), })), - findOne: vi.fn((query: { roleName: string }) => ({ - exec: vi.fn(async () => (query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null)), + findOne: vi.fn((query: { roleName?: string; isDefault?: boolean; enterpriseAppRole?: string }) => ({ + exec: vi.fn(() => { + if (query.enterpriseAppRole !== undefined) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault ? staffRoleDoc : null; + } + return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + }), })), prototype: {}, }); @@ -167,6 +173,36 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting a default staff role by enterpriseAppRole', ({ Given, When, Then, And }) => { + let result: Domain.Contexts.User.StaffRole.StaffRole; + Given('a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager"', () => { + staffRoleDoc = makeStaffRoleDoc({ + isDefault: true, + enterpriseAppRole: 'Staff.CaseManager', + }); + }); + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('I should receive a StaffRole domain object', () => { + expect(result).toBeInstanceOf(Domain.Contexts.User.StaffRole.StaffRole); + }); + And("the domain object's isDefault should be true", () => { + expect(result.isDefault).toBe(true); + }); + }); + + Scenario('Getting a default staff role by enterpriseAppRole that does not exist', ({ When, Then }) => { + let getDefaultRoleByEnterpriseAppRole: () => Promise; + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole"', () => { + getDefaultRoleByEnterpriseAppRole = async () => await repo.getDefaultRoleByEnterpriseAppRole('Staff.UnknownRole'); + }); + Then('an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found"', async () => { + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(); + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(/Default StaffRole with enterpriseAppRole Staff.UnknownRole not found/); + }); + }); + Scenario('Creating a new staff role instance', ({ When, Then, And }) => { let result: Domain.Contexts.User.StaffRole.StaffRole; When('I call getNewInstance with name "Supervisor"', async () => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts index 7d3e075a5..4cf3379e0 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts @@ -27,8 +27,36 @@ export class StaffRoleRepository return this.typeConverter.toDomain(staffRole, this.passport); } + async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { + const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); + if (!staffRole) { + throw new Error(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); + } + return this.typeConverter.toDomain(staffRole, this.passport); + } + getNewInstance(name: string): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewInstance(adapter, this.passport, name, false)); } + + getNewDefaultCaseManagerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultCaseManagerInstance(adapter, this.passport)); + } + + getNewDefaultServiceLineOwnerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultServiceLineOwnerInstance(adapter, this.passport)); + } + + getNewDefaultFinanceInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultFinanceInstance(adapter, this.passport)); + } + + getNewDefaultTechAdminInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultTechAdminInstance(adapter, this.passport)); + } } diff --git a/packages/ocom/persistence/src/datasources/readonly/index.test.ts b/packages/ocom/persistence/src/datasources/readonly/index.test.ts index 04d231428..536162ec4 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.test.ts @@ -1,13 +1,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; - -import type { Domain } from '@ocom/domain'; -import { ReadonlyDataSourceImplementation } from './index.ts'; import type { CommunityModelType } from '@ocom/data-sources-mongoose-models/community'; import type { MemberModelType } from '@ocom/data-sources-mongoose-models/member'; import type { EndUserModelType } from '@ocom/data-sources-mongoose-models/user/end-user'; +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; +import { ReadonlyDataSourceImplementation } from './index.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,6 +32,12 @@ function makeMockModelsContext() { create: vi.fn(), aggregate: vi.fn(), } as unknown as EndUserModelType, + StaffUser: { + findById: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffUserModelType, } as unknown as Parameters[0]; } @@ -101,6 +107,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the User property should have the correct structure', () => { expect(result.User).toHaveProperty('EndUser'); expect(result.User.EndUser).toHaveProperty('EndUserReadRepo'); + expect(result.User).toHaveProperty('StaffUser'); + expect(result.User.StaffUser).toHaveProperty('StaffUserReadRepo'); }); }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index d8940adb8..9342ba8ad 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -5,6 +5,7 @@ import { CommunityContext } from './community/index.ts'; import type * as Member from './community/member/index.ts'; import type * as EndUser from './user/end-user/index.ts'; import { UserContext } from './user/index.ts'; +import type * as StaffUser from './user/staff-user/index.ts'; export interface ReadonlyDataSource { Community: { @@ -19,6 +20,9 @@ export interface ReadonlyDataSource { EndUser: { EndUserReadRepo: EndUser.EndUserReadRepository; }; + StaffUser: { + StaffUserReadRepo: StaffUser.StaffUserReadRepository; + }; }; } diff --git a/packages/ocom/persistence/src/datasources/readonly/user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/index.ts index 54cb3892a..ab40bc6e7 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/index.ts @@ -1,7 +1,9 @@ import type { Domain } from '@ocom/domain'; import type { ModelsContext } from '../../../index.ts'; import { EndUserReadRepositoryImpl } from './end-user/index.ts'; +import { StaffUserReadRepositoryImpl } from './staff-user/index.ts'; export const UserContext = (models: ModelsContext, passport: Domain.Passport) => ({ EndUser: EndUserReadRepositoryImpl(models, passport), + StaffUser: StaffUserReadRepositoryImpl(models, passport), }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature new file mode 100644 index 000000000..9aa56131b --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature @@ -0,0 +1,23 @@ +Feature: StaffUserReadRepository + + Scenario: Creating StaffUserReadRepository throws when StaffUser model is missing + Given models context does not contain a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then it should throw an error with message "StaffUser model is not available in the mongoose context" + + Scenario: Creating StaffUserReadRepository succeeds when StaffUser model is present + Given models context contains a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then I should receive a StaffUserReadRepository instance + And the repository should have a getByExternalId method + + Scenario: getByExternalId returns entity when document is found + Given a StaffUser document exists with externalId "ext-abc" + When I call getByExternalId with "ext-abc" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByExternalId returns null when no document is found + Given no StaffUser document exists with externalId "missing-ext" + When I call getByExternalId with "missing-ext" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts new file mode 100644 index 000000000..75eef71cf --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; + +export type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +export const StaffUserReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffUserReadRepo: getStaffUserReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts new file mode 100644 index 000000000..c317f0709 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts @@ -0,0 +1,141 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; + +import type { Domain } from '@ocom/domain'; +import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; +import type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-user/staff-user.domain-adapter.ts', () => ({ + StaffUserConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.read-repository.feature')); + +function makeMockPassport() { + return { + user: { + forStaffUser: vi.fn(() => ({ + determineIf: vi.fn(() => true), + })), + }, + } as unknown as Domain.Passport; +} + +function makeMockStaffUserDocument() { + return { + _id: 'doc-id', + id: 'doc-id', + externalId: 'ext-abc', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + } as unknown as StaffUser; +} + +function makeMockModel(doc: StaffUser | null) { + return { + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + }), + } as unknown as StaffUserModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffUserReadRepository; + let mockStaffUserDoc: StaffUser; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockStaffUserDoc = makeMockStaffUserDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffUser, _passport: Domain.Passport) => ({ + id: mockStaffUserDoc.id, + externalId: mockStaffUserDoc.externalId, + })), + }; + + vi.mocked(StaffUserConverter).mockImplementation(function MockStaffUserConverter() { + return mockConverter as unknown as StaffUserConverter; + }); + }); + + Scenario('Creating StaffUserReadRepository throws when StaffUser model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffUser model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + try { + repository = getStaffUserReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffUser model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffUser model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffUserReadRepository succeeds when StaffUser model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffUser model', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + repository = getStaffUserReadRepository(models, passport); + }); + Then('I should receive a StaffUserReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getByExternalId method', () => { + expect(typeof repository.getByExternalId).toBe('function'); + }); + }); + + Scenario('getByExternalId returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with externalId "ext-abc"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-abc"', async () => { + result = await repository.getByExternalId('ext-abc'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByExternalId returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with externalId "missing-ext"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "missing-ext"', async () => { + result = await repository.getByExternalId('missing-ext'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts new file mode 100644 index 000000000..0824f8934 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts @@ -0,0 +1,35 @@ +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; + +export interface StaffUserReadRepository { + getByExternalId: (externalId: string) => Promise; +} + +class StaffUserReadRepositoryImpl implements StaffUserReadRepository { + private readonly model: StaffUserModelType; + private readonly converter: StaffUserConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffUser) { + throw new Error('StaffUser model is not available in the mongoose context'); + } + this.model = models.StaffUser; + this.converter = new StaffUserConverter(); + this.passport = passport; + } + + async getByExternalId(externalId: string): Promise { + const doc = await this.model.findOne({ externalId }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffUserReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffUserReadRepository => { + return new StaffUserReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/service-token-validation/src/index.test.ts b/packages/ocom/service-token-validation/src/index.test.ts index 8bf1747cb..203525c8e 100644 --- a/packages/ocom/service-token-validation/src/index.test.ts +++ b/packages/ocom/service-token-validation/src/index.test.ts @@ -228,7 +228,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Mock successful verification on second attempt mockGetVerifiedJwt - .mockResolvedValueOnce(null) // First provider fails + .mockRejectedValueOnce(Object.assign(new Error('signature verification failed'), { name: 'JWSSignatureVerificationFailed' })) // First provider fails with signature mismatch .mockResolvedValueOnce({ // Second provider succeeds payload: { sub: 'user123', aud: 'audience2' }, diff --git a/packages/ocom/service-token-validation/src/index.ts b/packages/ocom/service-token-validation/src/index.ts index b002722c6..c8824fa05 100644 --- a/packages/ocom/service-token-validation/src/index.ts +++ b/packages/ocom/service-token-validation/src/index.ts @@ -39,12 +39,18 @@ export class ServiceTokenValidation implements ServiceBase { async verifyJwt(token: string): Promise | null> { // Try each config key for verification for (const configKey of this.tokenSettings.keys()) { - const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); - if (result?.payload) { - return { - verifiedJwt: result.payload as ClaimsType, - openIdConfigKey: configKey, - }; + try { + const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); + if (result?.payload) { + return { + verifiedJwt: result.payload as ClaimsType, + openIdConfigKey: configKey, + }; + } + } catch (error) { + if (!this.isRetryableVerificationError(error)) { + throw error; + } } } return null; @@ -74,4 +80,12 @@ export class ServiceTokenValidation implements ServiceBase { return defaultValue; } } + + private isRetryableVerificationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ['JWSSignatureVerificationFailed', 'JWTClaimValidationFailed', 'JWTExpired', 'JWTInvalid', 'JWSInvalid'].includes(error.name); + } } diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx index 4930c0c3b..3fc91da04 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx @@ -80,6 +80,7 @@ const mockData = { export const Default: Story = { args: { data: mockData, + canCreateCommunity: true, } satisfies CommunityListProps, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx index c0f38203c..901ed7afc 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx @@ -16,7 +16,6 @@ export interface CommunityListProps { export const CommunityList: React.FC = (props) => { const [communityList, setCommunityList] = useState(props.data.communities); const navigate = useNavigate(); - const onChange = (event: ChangeEvent) => { const searchValue = event.target.value; if (searchValue === '') { @@ -120,12 +119,12 @@ export const CommunityList: React.FC = (props) => {

Navigate to a Community

- +
{ const pageLayouts: PageLayoutProps[] = [ { @@ -21,7 +27,10 @@ export const Admin: React.FC = () => { icon: , id: 2, parent: 'ROOT', - // hasPermissions: (member: Member) => member?.isAdmin ?? false + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, { path: '/community/:communityId/admin/:memberId/settings/*', @@ -29,9 +38,10 @@ export const Admin: React.FC = () => { icon: , id: 3, parent: 'ROOT', - // Note: Permission check would be: - // hasPermissions: (member: Member) => member?.role?.permissions?.communityPermissions?.canManageCommunitySettings ?? false - // Currently schema doesn't include role/permissions, so we allow all admin users to access settings + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, ]; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx index f446d084d..02fedcf53 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx @@ -2,7 +2,10 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import type { PageLayoutProps } from '@ocom/ui-shared'; import { useParams } from 'react-router-dom'; -import { AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type Member } from './generated.tsx'; +import { + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, + type Member, +} from './generated.tsx'; import { SectionLayout } from './section-layout.tsx'; interface SectionLayoutContainerProps { @@ -14,6 +17,7 @@ export const SectionLayoutContainer: React.FC = (pr const { data: membersData, loading: membersLoading, error: membersError } = useQuery(AdminSectionLayoutContainerMembersForCurrentEndUserDocument); + return ( [ + { + path: '/community/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: '/community/:communityId/admin/:memberId/members/*', + title: 'Members', + icon: , + id: 2, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageUsers ?? false, + }, + { + path: '/community/:communityId/admin/:memberId/settings/*', + title: 'Settings', + icon: , + id: 3, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageCommunities ?? false, + }, +]; + +const meta: Meta = { + title: 'Admin/Layouts/SectionLayout', + component: SectionLayout, + decorators: [ + (Story) => ( + + + + } + /> + + + + ), + ], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPermissions: Story = { + args: { + pageLayouts: makePageLayouts(allPermissions), + memberData: mockMember, + staffSectionPermissions: allPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const NoPermissions: Story = { + args: { + pageLayouts: makePageLayouts(noPermissions), + memberData: mockMember, + staffSectionPermissions: noPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const CommunityPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageCommunities: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageCommunities: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const UserPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageUsers: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageUsers: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const NullPermissions: Story = { + args: { + pageLayouts: makePageLayouts(null), + memberData: mockMember, + staffSectionPermissions: null, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.tsx index 85e655538..846dbfb20 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.tsx @@ -21,6 +21,13 @@ const handleToggler = (isExpanded: boolean, setIsExpanded: (value: boolean) => v } }; +export interface AdminStaffSectionPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + interface AdminSectionLayoutProps { pageLayouts: PageLayoutProps[]; memberData: Member; @@ -36,7 +43,7 @@ export const SectionLayout: React.FC = (props) => { const menuComponentProps: MenuComponentProps = { pageLayouts: props.pageLayouts, - memberData: props.memberData, + memberData: { member: props.memberData }, theme: 'light', mode: 'inline', }; diff --git a/packages/ocom/ui-shared/package.json b/packages/ocom/ui-shared/package.json index 90d154655..b0f02e571 100644 --- a/packages/ocom/ui-shared/package.json +++ b/packages/ocom/ui-shared/package.json @@ -53,7 +53,7 @@ "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", - "@vitest/browser": "^4.1.2", + "@vitest/browser": "catalog:", "@vitest/coverage-istanbul": "catalog:", "jsdom": "catalog:", "rimraf": "catalog:", diff --git a/packages/ocom/ui-staff-route-community-management/src/index.tsx b/packages/ocom/ui-staff-route-community-management/src/index.tsx index 90ceaf1cc..3bbe9a551 100644 --- a/packages/ocom/ui-staff-route-community-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-community-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-finance/src/index.tsx b/packages/ocom/ui-staff-route-finance/src/index.tsx index c7116f4ad..fb0360d17 100644 --- a/packages/ocom/ui-staff-route-finance/src/index.tsx +++ b/packages/ocom/ui-staff-route-finance/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-user-management/package.json b/packages/ocom/ui-staff-route-user-management/package.json index 604ff613d..435e1b34d 100644 --- a/packages/ocom/ui-staff-route-user-management/package.json +++ b/packages/ocom/ui-staff-route-user-management/package.json @@ -16,6 +16,7 @@ "dependencies": { "@ant-design/icons": "catalog:", "@ocom/ui-staff-shared": "workspace:*", + "@graphql-typed-document-node/core": "^3.2.0", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:" diff --git a/packages/ocom/ui-staff-route-user-management/src/index.tsx b/packages/ocom/ui-staff-route-user-management/src/index.tsx index 33b2a3f38..f2c3911df 100644 --- a/packages/ocom/ui-staff-route-user-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json index c8c36c54e..07aaa703d 100644 --- a/packages/ocom/ui-staff-shared/package.json +++ b/packages/ocom/ui-staff-shared/package.json @@ -15,9 +15,11 @@ "test:watch": "vitest" }, "dependencies": { + "@apollo/client": "^3.13.9", "@ant-design/icons": "catalog:", - "@ocom/ui-shared": "workspace:*", + "@cellix/ui-core": "workspace:*", "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/ui-shared": "workspace:*", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:", @@ -28,7 +30,10 @@ "@cellix/config-vitest": "workspace:*", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", + "@storybook/react": "^9.1.9", + "storybook": "catalog:", "jsdom": "catalog:", + "react-dom": "^19.1.1", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-shared/src/index.tsx b/packages/ocom/ui-staff-shared/src/index.tsx index ede529df0..d28d20e4c 100644 --- a/packages/ocom/ui-staff-shared/src/index.tsx +++ b/packages/ocom/ui-staff-shared/src/index.tsx @@ -2,7 +2,10 @@ import React, { createElement, type FC } from 'react'; import { SectionLayout } from './section-layout.tsx'; export { VerticalTabs } from '@ocom/ui-shared'; +export { RequireRole, type RequireRoleProps } from './require-role.tsx'; export { SectionLayout, type SectionLayoutProps } from './section-layout.tsx'; +export { SectionLayoutContainer } from './section-layout.container.tsx'; +export { extractRoles, type StaffAppRole, StaffAppRoles, staffRouteRoles } from './staff-app-roles.ts'; export { type StaffAuth, StaffAuthContext, StaffAuthProvider, StaffRouteShell, type StaffRouteShellProps } from './staff-route-shell.tsx'; export { SubPageLayout } from './sub-page-layout.tsx'; @@ -19,20 +22,13 @@ import { StaffAuthContext } from './staff-route-shell.tsx'; export const PlaceholderPage: React.FC = ({ sectionName, description, expectedRoles, explicitRoles }) => { const auth = React.useContext(StaffAuthContext); - const resolvedRoles = React.useMemo(() => { + const resolvedPermissions = React.useMemo(() => { if (explicitRoles && explicitRoles.length > 0) return explicitRoles; - if (auth) { - const a = auth as StaffAuth; - if (Array.isArray(a.roles) && a.roles.length > 0) return a.roles as string[]; - type RawProfile = { roles?: unknown; role?: unknown }; - const raw = a.raw as RawProfile | undefined; - if (raw) { - const maybe = raw.roles ?? raw.role ?? undefined; - if (Array.isArray(maybe)) return maybe as string[]; - if (typeof maybe === 'string') return [maybe]; - } - } - return []; + const perms = auth?.permissions; + if (!perms) return []; + return Object.entries(perms) + .filter(([, isEnabled]) => isEnabled === true) + .map(([permKey]) => permKey); }, [auth, explicitRoles]); const identitySummary = React.useMemo<{ displayName: string; identifier: string | undefined } | null>(() => { @@ -70,11 +66,11 @@ export const PlaceholderPage: React.FC = ({ sectionName, descr )}
-
Resolved Roles
- {resolvedRoles && resolvedRoles.length > 0 ? ( +
Resolved Permissions
+ {resolvedPermissions.length > 0 ? (
    - {resolvedRoles.map((r) => ( -
  • {r}
  • + {resolvedPermissions.map((permission) => ( +
  • {permission}
  • ))}
) : ( diff --git a/packages/ocom/ui-staff-shared/src/require-role.stories.tsx b/packages/ocom/ui-staff-shared/src/require-role.stories.tsx new file mode 100644 index 000000000..c924fc392 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.stories.tsx @@ -0,0 +1,158 @@ +import { gql } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; +import { expect, within } from 'storybook/test'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { RequireRole, type RequireRoleProps } from './require-role.tsx'; + +const REQUIRE_ROLE_STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +const protectedPermissions = { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: true }, +}; + +const deniedPermissions = { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, +}; + +const meta = { + title: 'Staff/RequireRole', + component: RequireRole, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const ProtectedContent = () =>
protected content
; + +const routeWrapper = (story: ReactElement) => ( + + + unauthorized
} + /> + +); + +export const Authorized: Story = { + args: { + roles: [], + permKey: 'canManageTechAdmin', + children: , + } satisfies RequireRoleProps, + parameters: { + memoryRouter: { + initialEntries: ['/staff/tech'], + }, + }, + decorators: [ + (Story) => ( + + {routeWrapper()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('protected content')).resolves.toBeInTheDocument(); + expect(canvas.queryByText('unauthorized')).not.toBeInTheDocument(); + }, +}; + +export const Unauthorized: Story = { + args: { + roles: [], + permKey: 'canManageTechAdmin', + children: , + } satisfies RequireRoleProps, + parameters: { + memoryRouter: { + initialEntries: ['/staff/tech'], + }, + }, + decorators: [ + (Story) => ( + + {routeWrapper()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('unauthorized')).resolves.toBeInTheDocument(); + expect(canvas.queryByText('protected content')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-shared/src/require-role.tsx b/packages/ocom/ui-staff-shared/src/require-role.tsx new file mode 100644 index 000000000..71f2b6d02 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.tsx @@ -0,0 +1,81 @@ +import { gql, useQuery } from '@apollo/client'; +import type { FC, ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import type { StaffAuth } from './staff-route-shell.tsx'; + +export interface RequireRoleProps { + /** Deprecated. Frontend authorization must use backend permission flags. */ + roles: readonly string[]; + /** Gate by backend permission flag. */ + permKey?: keyof NonNullable; + children: ReactNode; +} + +const STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffUserCurrentQueryResult { + staffUserCurrent: { + role?: { + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const RequireRole: FC = ({ roles, permKey, children }) => { + void roles; + const { data, loading, error } = useQuery(STAFF_USER_CURRENT_QUERY, { + fetchPolicy: 'cache-first', + }); + + if (loading) { + return null; + } + + const rolePermissions = data?.staffUserCurrent?.role?.permissions; + const permissions: NonNullable | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities, + canManageUsers: rolePermissions.userPermissions.canManageUsers, + canManageFinance: rolePermissions.financePermissions.canManageFinance, + canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin, + } + : undefined; + const isAuthorized = permKey !== undefined && permissions?.[permKey] === true; + + if (error || !isAuthorized) { + return ( + + ); + } + + return <>{children}; +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout-header.graphql b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql new file mode 100644 index 000000000..e744d3fde --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql @@ -0,0 +1,13 @@ +query SectionLayoutHeaderCurrentStaffUser { + currentStaffUserAndCreateIfNotExists { + ...SectionLayoutHeaderStaffUserFields + } +} + +fragment SectionLayoutHeaderStaffUserFields on StaffUser { + id + displayName + firstName + lastName + email +} diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx new file mode 100644 index 000000000..c07ff60ee --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.stories.tsx @@ -0,0 +1,123 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type { ReactElement } from 'react'; +import { expect, within } from 'storybook/test'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { SectionLayoutContainer } from './section-layout.container.tsx'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { StaffAuthProvider } from './staff-route-shell.tsx'; + +const pageLayouts: PageLayoutProps[] = [ + { + path: '/staff/custom', + title: 'Custom', + icon: , + id: 'ROOT', + }, +]; + +const meta = { + title: 'Staff/SectionLayoutContainer', + component: SectionLayoutContainer, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const renderContainer = (story: ReactElement) => ( + + + + + + + +); + +export const WithDisplayName: Story = { + args: { + pageLayouts, + } satisfies { pageLayouts: PageLayoutProps[] }, + decorators: [ + (Story) => ( + + {renderContainer()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('Jess')).resolves.toBeInTheDocument(); + expect(canvas.getByText('Custom')).toBeInTheDocument(); + }, +}; + +export const FallsBackToAuthName: Story = { + args: { + pageLayouts, + } satisfies { pageLayouts: PageLayoutProps[] }, + decorators: [ + (Story) => ( + + {renderContainer()} + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.findByText('Fallback Name')).resolves.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx new file mode 100644 index 000000000..4209b71d6 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type React from 'react'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +interface SectionLayoutContainerProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayoutContainer: React.FC = (props) => { + const { data: staffUserData, loading: staffUserLoading, error: staffUserError } = useQuery( + SectionLayoutHeaderCurrentStaffUserDocument, + { + fetchPolicy: 'cache-first', + }, + ); + + const displayName = staffUserData?.currentStaffUserAndCreateIfNotExists?.displayName; + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayoutContainer] GraphQL query result:', { + loading: staffUserLoading, + error: staffUserError?.message, + staffUserData, + extractedDisplayName: displayName, + }); + } + } + + const sectionLayoutProps: React.ComponentProps = { + pageLayouts: props.pageLayouts, + // Always pass displayName (even if undefined) so the component can properly handle fallback chain + ...(displayName && { displayName }), + }; + + return ( + } + error={staffUserError} + /> + ); +}; + diff --git a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx index bd43e89e1..f2d05c4cb 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx @@ -18,36 +18,81 @@ const renderIntoDocument = (node: React.ReactNode) => { }; describe('SectionLayout merging behaviour', () => { - it('renders canonical staff navigation merged with consumer pageLayouts', async () => { - const consumerLayouts = [ - { - path: '/staff/community-management', - title: 'Community Management', - icon: , - id: 'ROOT', - }, - ]; + it('renders only the menu items the user has permission for', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + it('shows no menu items when permissions are undefined (loading or no role assigned)', async () => { const container = renderIntoDocument( } + element={} /> , ); - // Wait a tick for ant design components to mount await new Promise((r) => setTimeout(r, 10)); - // Top-level menu items expected - expect(container.textContent).not.toContain('Home'); - expect(container.textContent).toContain('Communities'); - expect(container.textContent).toContain('Users'); + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).not.toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + + it('renders finance menu from JWT role when backend permissions are unavailable', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); expect(container.textContent).toContain('Finance'); - expect(container.textContent).toContain('Tech Admin'); + expect(container.textContent).not.toContain('Tech Admin'); }); it('preserves default parent when consumer entry omits parent field', async () => { @@ -63,12 +108,23 @@ describe('SectionLayout merging behaviour', () => { const container = renderIntoDocument( - - } - /> - + + + } + /> + + , ); @@ -142,3 +198,93 @@ describe('PlaceholderPage', () => { expect(container.textContent).toContain('m@example.com'); }); }); + +describe('SectionLayout with displayName prop', () => { + it('renders displayName from prop when provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Alice Johnson'); + expect(container.textContent).toContain('Log Out'); + }); + + it('falls back to auth context name when displayName prop is not provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Bob Smith'); + }); + + it('uses displayName prop over auth context when both are available', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Prop Name'); + expect(container.textContent).not.toContain('Auth Name'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx index cd27b722f..45cc05295 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx @@ -30,10 +30,27 @@ export interface SectionLayoutProps { headerContent?: React.ReactNode; /** Optional injected logged in user component (extension slot). */ loggedInUser?: React.ReactNode; + /** Optional displayName from container (e.g., from GraphQL query). When provided, takes priority over auth context. */ + displayName?: string; } export const SectionLayout: React.FC = (props) => { const auth = useContext(StaffAuthContext); + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayout] Component props & fallback chain:', { + propsDisplayName: props.displayName, + authName: auth?.name, + authUsername: auth?.username, + authEmail: auth?.email, + resolvedDisplayName: props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User', + }); + } + } + // Guard access to localStorage so this component is safe during server-side rendering (no globalThis/localStorage) const [isExpanded, setIsExpanded] = useState(() => { if (typeof globalThis === 'undefined') return true; // default to expanded during SSR @@ -54,35 +71,39 @@ export const SectionLayout: React.FC = (props) => { // Merge canonical staff navigation with consumer-provided pageLayouts. // Defaults are added only when the consumer hasn't provided an entry with the same id. // Consumer-provided entries override defaults when ids conflict. - const defaultPageLayouts: PageLayoutProps[] = [ - { - path: '/staff/community-management', - title: 'Communities', - icon: , - id: 'ROOT', - }, - { - path: '/staff/user-management/*', - title: 'Users', - icon: , - id: 'users', - parent: 'ROOT', - }, - { - path: '/staff/finance/*', - title: 'Finance', - icon: , - id: 'finance', - parent: 'ROOT', - }, - { - path: '/staff/tech/*', - title: 'Tech Admin', - icon: , - id: 'tech', - parent: 'ROOT', - }, - ]; + // Build default page layouts from backend permissions. + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {}; + + // Construct default page layouts ensuring a ROOT entry always exists so MenuComponent renders. + // If Communities is allowed, keep the historic behaviour: Communities is ROOT and others are its children. + // Otherwise, promote the first available section to ROOT so a finance-only user sees a single Finance item. + const defaultPageLayouts: PageLayoutProps[] = []; + + if (canManageCommunities) { + // Communities as canonical root, others as children + defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' }); + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); + if (canManageFinance) defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'finance', ...nestedParentProps }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', ...nestedParentProps }); + } else { + // No Communities root. Promote the first available section to ROOT to render a single top-level item. + if (canManageFinance) { + defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'ROOT' }); + // add others as children if present + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageUsers) { + defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageTechAdmin) { + defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'ROOT' }); + } + } // Build a map from default entries, then overlay consumer entries so consumers can override defaults. // When consumers provide an entry with the same id, merge it with the default so that @@ -160,7 +181,7 @@ export const SectionLayout: React.FC = (props) => { marginLeft: 'auto', }} > - Staff User + {props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User'}