From 3f625e4d79417df0b11a01d7196ae1119d74c61c Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 9 Jul 2026 15:07:51 -0400 Subject: [PATCH 1/2] Add additional e2e tests --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index b1d6a7ac1..ed0da3ce5 100644 --- a/readme.md +++ b/readme.md @@ -63,6 +63,7 @@ ShareThrift is a web‑based peer‑to‑peer sharing platform that enables peop - Draft, publish, pause, cancel, appeal, and reinstate listings - AI‑assisted draft creation using the integrated chatbot + ### 📅 Reservation Lifecycle - Calendar‑based booking From 4dfe332c20b467c37076431b27f417ab0646a934 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 9 Jul 2026 16:07:26 -0400 Subject: [PATCH 2/2] additionakl e2e test additions --- .snyk | 5 + apps/ui-sharethrift/vite.config.ts | 43 +++++ package.json | 5 +- .../e2e-tests/cucumber.js | 2 +- .../account/pages/account-settings.page.ts | 25 +++ .../questions/account-settings-content.ts | 17 ++ .../view-account-settings.steps.ts | 16 ++ .../account/tasks/open-account-settings.ts | 43 +++++ .../src/contexts/home/pages/home.page.ts | 43 +++++ .../home/questions/home-page-content.ts | 28 ++++ .../step-definitions/browse-listings.steps.ts | 35 ++++ .../contexts/home/tasks/browse-listings.ts | 36 ++++ .../pages/listing-management.page.ts | 20 +++ .../questions/listing-management-content.ts | 16 ++ .../step-definitions/manage-listings.steps.ts | 25 +++ .../tasks/manage-listings.ts | 44 +++++ .../contexts/messages/pages/messages.page.ts | 35 ++++ .../messages/questions/messaging-workspace.ts | 17 ++ .../step-definitions/view-messages.steps.ts | 24 +++ .../messages/tasks/create-conversation.ts | 82 +++++++++ .../contexts/messages/tasks/open-messages.ts | 46 +++++ .../pages/reservations.page.ts | 16 ++ .../manage-reservations.steps.ts | 24 +++ .../tasks/review-reservations.ts | 29 ++++ .../e2e-tests/src/infrastructure.ts | 11 +- .../account/view-account-settings.feature | 14 ++ .../scenarios/home/browse-listings.feature | 26 +++ .../scenarios/listing/manage-listings.feature | 19 +++ .../scenarios/messages/view-messages.feature | 22 +++ .../manage-reservations.feature | 15 ++ .../e2e-tests/src/servers/index.ts | 1 + .../src/servers/test-messaging-server.ts | 12 ++ .../src/shared/environment/app-paths.ts | 1 + .../src/shared/pages/navigation.page.ts | 28 ++++ .../src/models/user/personal-user.model.ts | 157 +++++++----------- .../components/settings-view.container.tsx | 119 +++---------- pnpm-lock.yaml | 102 ++++++++---- 37 files changed, 981 insertions(+), 222 deletions(-) create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/account/pages/account-settings.page.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/account/questions/account-settings-content.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/account/step-definitions/view-account-settings.steps.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/account/tasks/open-account-settings.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/home/pages/home.page.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/home/questions/home-page-content.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/home/step-definitions/browse-listings.steps.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/home/tasks/browse-listings.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/listing-management/pages/listing-management.page.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/listing-management/questions/listing-management-content.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/listing-management/step-definitions/manage-listings.steps.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/listing-management/tasks/manage-listings.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/messages/pages/messages.page.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/messages/questions/messaging-workspace.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/messages/step-definitions/view-messages.steps.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/create-conversation.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/open-messages.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/pages/reservations.page.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/step-definitions/manage-reservations.steps.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/tasks/review-reservations.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/scenarios/account/view-account-settings.feature create mode 100644 packages/sthrift-verification/e2e-tests/src/scenarios/home/browse-listings.feature create mode 100644 packages/sthrift-verification/e2e-tests/src/scenarios/listing/manage-listings.feature create mode 100644 packages/sthrift-verification/e2e-tests/src/scenarios/messages/view-messages.feature create mode 100644 packages/sthrift-verification/e2e-tests/src/scenarios/reservation-request/manage-reservations.feature create mode 100644 packages/sthrift-verification/e2e-tests/src/servers/test-messaging-server.ts create mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/navigation.page.ts diff --git a/.snyk b/.snyk index ed3016e1e..ddd17d437 100644 --- a/.snyk +++ b/.snyk @@ -1,5 +1,10 @@ version: v1.5.0 ignore: + 'SNYK-JS-OPENTELEMETRYPROPAGATORJAEGER-17901201': + - '* > @opentelemetry/propagator-jaeger': + reason: 'Transitive dependency bundled by @opentelemetry/sdk-node; the fix requires a major-version upgrade of the whole otel suite (deferred, same as SNYK-JS-OPENTELEMETRYCORE). Trace context is internal, not user-controlled.' + expires: '2026-10-08T00:00:00.000Z' + created: '2026-07-09T00:00:00.000Z' 'SNYK-JS-SIRV-12558119': - '* > sirv@2.0.4': reason: 'Transitive dependency in Docusaurus; not exploitable in static site serving context (dev-only asset handler)' diff --git a/apps/ui-sharethrift/vite.config.ts b/apps/ui-sharethrift/vite.config.ts index aa0aa8afd..0427fc0ef 100644 --- a/apps/ui-sharethrift/vite.config.ts +++ b/apps/ui-sharethrift/vite.config.ts @@ -9,6 +9,49 @@ export default defineConfig(() => { // Skip auto-opening a browser under E2E, where a headless Playwright // browser drives the app and a real window must not be spawned. open: process.env.E2E === 'true' ? false : 'https://sharethrift.localhost:1355', + // Pre-transform the app entry at server startup so the first navigation + // to a route doesn't stall while Vite compiles the module graph. + warmup: { + clientFiles: ['./src/main.tsx'], + }, + }, + // The UI is composed from linked workspace packages (@sthrift/ui-shared and the + // route packages) that resolve to source. By default Vite does NOT pre-bundle + // linked packages, so it serves every one of their hundreds of component files as + // a separate module request. Behind the portless HTTP/2 proxy, that request storm + // intermittently fails with net::ERR_HTTP2_PROTOCOL_ERROR, which breaks the render + // and leaves a ~10-15s white screen until the page reloads. + // + // Including these packages forces Vite to pre-bundle each into a single optimized + // chunk, collapsing hundreds of parallel /@fs/ requests into a handful and removing + // the HTTP/2 failure mode. The third-party heavy deps are listed too so they are + // bundled up front rather than discovered on-demand (which triggers a re-optimize + // + full-page reload). + // + // Trade-off: edits to these workspace packages trigger a Vite re-optimize (full + // reload) instead of granular HMR. If you are actively iterating inside one of + // them, temporarily remove it from this list. + optimizeDeps: { + include: [ + '@sthrift/ui-shared', + '@sthrift/ui-sharethrift-route-root', + '@sthrift/ui-sharethrift-route-shared', + '@sthrift/ui-sharethrift-route-signup', + 'antd', + '@ant-design/icons', + '@ant-design/v5-patch-for-react-19', + '@apollo/client', + '@apollo/client/react', + '@apollo/client/link/http', + '@apollo/client/link/context', + '@apollo/client/link/batch-http', + '@apollo/client/link/persisted-queries', + 'graphql', + 'lodash', + 'rxjs', + 'react-oidc-context', + 'react-router-dom', + ], }, }; }); diff --git a/package.json b/package.json index f17c06b26..d44a051c4 100644 --- a/package.json +++ b/package.json @@ -86,10 +86,13 @@ "@apollo/server": ">=5.5.0", "@types/node": "^24.10.7", "axios": "1.16.0", + "body-parser@2.2.2": "2.3.0", "brace-expansion@5.0.4": "5.0.5", "express@4": ">=4.22.2", "follow-redirects": "1.16.0", "happy-dom@20.8.4": "20.8.9", + "js-yaml@3.14.2": "3.15.0", + "js-yaml@4.1.1": "4.3.0", "lodash": "^4.18.1", "minimatch": ">=10.2.3", "node-forge@1.3.3": "1.4.0", @@ -97,7 +100,7 @@ "picomatch@2.3.1": "2.3.2", "picomatch@4.0.3": "4.0.4", "postcss": "8.5.10", - "protobufjs": "7.5.5", + "protobufjs": "7.6.5", "qs": ">=6.14.2", "serialize-javascript@6.0.2": "7.0.5", "serialize-javascript@7.0.4": "7.0.5", diff --git a/packages/sthrift-verification/e2e-tests/cucumber.js b/packages/sthrift-verification/e2e-tests/cucumber.js index 14e7d5488..ba51a337d 100644 --- a/packages/sthrift-verification/e2e-tests/cucumber.js +++ b/packages/sthrift-verification/e2e-tests/cucumber.js @@ -3,7 +3,7 @@ import { isAgent } from 'std-env'; const terminalFormat = isAgent ? '@cellix/serenity-framework/formatters/agent' : 'progress-bar'; export default { - paths: ['../verification-shared/src/scenarios/**/*.feature'], + paths: ['../verification-shared/src/scenarios/**/*.feature', 'src/scenarios/**/*.feature'], import: ['src/world.ts', 'src/contexts/**/step-definitions/**/*.steps.ts', 'src/shared/**/*.ts'], format: [terminalFormat, 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], formatOptions: { diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/account/pages/account-settings.page.ts b/packages/sthrift-verification/e2e-tests/src/contexts/account/pages/account-settings.page.ts new file mode 100644 index 000000000..a53892cd0 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/account/pages/account-settings.page.ts @@ -0,0 +1,25 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; +export class AccountSettingsPage { + constructor(private readonly adapter: PageAdapter) {} + get profileInformation() { + return this.adapter.getByText('Profile Information'); + } + get editProfileButton() { + return this.adapter.getByRole('button', { name: 'Edit Profile' }); + } + get firstNameInput() { + return this.adapter.getByLabel('First Name'); + } + get saveButton() { + return this.adapter.getByRole('button', { name: 'Save' }); + } + firstName(value: string) { + return this.adapter.getByText(value, { selector: '.ant-card' }); + } + async changeFirstName(value: string): Promise { + await this.editProfileButton.click(); + await this.firstNameInput.waitFor({ state: 'visible', timeout: 15_000 }); + await this.firstNameInput.fill(value); + await this.saveButton.click(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/account/questions/account-settings-content.ts b/packages/sthrift-verification/e2e-tests/src/contexts/account/questions/account-settings-content.ts new file mode 100644 index 000000000..52395e058 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/account/questions/account-settings-content.ts @@ -0,0 +1,17 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { AccountSettingsPage } from '../pages/account-settings.page.ts'; +export const AccountSettingsContent = Question.about('editable account settings', async (actor: AnswersQuestions & UsesAbilities) => { + const view = new AccountSettingsPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); + await view.profileInformation.waitFor({ state: 'visible', timeout: 15_000 }); + return (await view.profileInformation.isVisible()) && (await view.editProfileButton.isVisible()); +}); + +export const SavedFirstName = (value: string) => + Question.about(`saved first name "${value}"`, async (actor: AnswersQuestions & UsesAbilities) => { + const view = new AccountSettingsPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); + const name = view.firstName(value); + await name.waitFor({ state: 'visible', timeout: 15_000 }); + return name.isVisible(); + }); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/account/step-definitions/view-account-settings.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/account/step-definitions/view-account-settings.steps.ts new file mode 100644 index 000000000..15a44c99a --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/account/step-definitions/view-account-settings.steps.ts @@ -0,0 +1,16 @@ +import { Then, When } from '@cucumber/cucumber'; +import { Ensure, equals } from '@serenity-js/assertions'; +import { actorCalled } from '@serenity-js/core'; +import type { ShareThriftWorld } from '../../../world.ts'; +import { SavedFirstName } from '../questions/account-settings-content.ts'; +import { ChangeFirstName, OpenAccountSettings } from '../tasks/open-account-settings.ts'; + +When('{word} opens her account settings', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(OpenAccountSettings.fromNavigation()); +}); +When('{word} changes her first name to {string}', async function (this: ShareThriftWorld, name: string, value: string) { + await actorCalled(name).attemptsTo(ChangeFirstName.to(value)); +}); +Then('{word} should see the saved first name {string}', async function (this: ShareThriftWorld, name: string, value: string) { + await actorCalled(name).attemptsTo(Ensure.that(SavedFirstName(value), equals(true))); +}); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/account/tasks/open-account-settings.ts b/packages/sthrift-verification/e2e-tests/src/contexts/account/tasks/open-account-settings.ts new file mode 100644 index 000000000..47eee8754 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/account/tasks/open-account-settings.ts @@ -0,0 +1,43 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Task } from '@serenity-js/core'; +import { NavigationPage } from '../../../shared/pages/navigation.page.ts'; +import { AccountSettingsPage } from '../pages/account-settings.page.ts'; +export class OpenAccountSettings extends Task { + static fromNavigation() { + return new OpenAccountSettings(); + } + private constructor() { + super('opens account settings from the navigation'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new NavigationPage(new PlaywrightPageAdapter(page)).open('Settings'); + } +} + +export class ChangeFirstName extends Task { + static to(value: string) { + return new ChangeFirstName(value); + } + private constructor(private readonly value: string) { + super(`changes the first name to "${value}"`); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + const updateResponse = page.waitForResponse((response) => { + const body = response.request().postData(); + return Boolean(body?.includes('HomeAccountSettingsViewContainerUpdatePersonalUser') && body.includes('"query"')); + }); + await new AccountSettingsPage(new PlaywrightPageAdapter(page)).changeFirstName(this.value); + const responseBody = (await (await updateResponse).json()) as + | { data?: { personalUserUpdate?: { status?: { success?: boolean; errorMessage?: string } } } } + | Array<{ data?: { personalUserUpdate?: { status?: { success?: boolean; errorMessage?: string } } } }>; + const json = Array.isArray(responseBody) ? responseBody[0] : responseBody; + if (!json.data?.personalUserUpdate?.status?.success) { + throw new Error(`Saving account details failed: ${json.data?.personalUserUpdate?.status?.errorMessage ?? JSON.stringify(json)}`); + } + await page.waitForLoadState('networkidle').catch(() => undefined); + await page.reload({ waitUntil: 'domcontentloaded' }); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/home/pages/home.page.ts b/packages/sthrift-verification/e2e-tests/src/contexts/home/pages/home.page.ts new file mode 100644 index 000000000..49d9af7a0 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/home/pages/home.page.ts @@ -0,0 +1,43 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; + +export class HomePage { + constructor(private readonly adapter: PageAdapter) {} + + get searchInput() { + return this.adapter.getByPlaceholder('Search'); + } + get categoryFilter() { + return this.adapter.getByText('All'); + } + get locationFilter() { + return this.adapter.getByText('Philadelphia, PA'); + } + listing(title: string) { + return this.adapter.getByText(title); + } + selectedCategory(category: string) { + return this.adapter.locator(`.ant-select-selection-item[title="${category}"]:visible`); + } + + async visit(): Promise { + await this.adapter.goto('/', { waitUntil: 'domcontentloaded' }); + await this.searchInput.waitFor({ state: 'visible', timeout: 15_000 }); + } + + async searchFor(query: string): Promise { + await this.searchInput.fill(query); + await this.adapter.locator('button:has(.anticon-search):visible').click(); + } + + async openListing(title: string): Promise { + await this.listing(title).click(); + await this.adapter.waitForURL(/\/listing\//, { timeout: 15_000 }); + } + + async filterByCategory(category: string): Promise { + await this.adapter.locator('.ant-select-selector:visible').click(); + const option = this.adapter.locator(`.ant-select-item-option[title="${category}"]:visible`); + await option.waitFor({ state: 'visible', timeout: 15_000 }); + await option.click(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/home/questions/home-page-content.ts b/packages/sthrift-verification/e2e-tests/src/contexts/home/questions/home-page-content.ts new file mode 100644 index 000000000..a2f22d3a0 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/home/questions/home-page-content.ts @@ -0,0 +1,28 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { HomePage } from '../pages/home.page.ts'; + +function pageFor(actor: AnswersQuestions & UsesAbilities) { + return new HomePage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); +} + +export const DiscoveryControls = Question.about('the home discovery controls', async (actor: AnswersQuestions & UsesAbilities) => { + const home = pageFor(actor); + await home.searchInput.waitFor({ state: 'visible', timeout: 15_000 }); + return (await home.categoryFilter.isVisible()) && (await home.locationFilter.isVisible()); +}); + +export const ListingInDiscovery = (title: string) => + Question.about(`listing "${title}" on the home page`, async (actor: AnswersQuestions & UsesAbilities) => { + const listing = pageFor(actor).listing(title); + await listing.waitFor({ state: 'visible', timeout: 15_000 }); + return listing.isVisible(); + }); + +export const SelectedCategory = (category: string) => + Question.about(`selected category "${category}"`, async (actor: AnswersQuestions & UsesAbilities) => { + const selected = pageFor(actor).selectedCategory(category); + await selected.waitFor({ state: 'visible', timeout: 15_000 }); + return selected.isVisible(); + }); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/home/step-definitions/browse-listings.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/home/step-definitions/browse-listings.steps.ts new file mode 100644 index 000000000..432c3c752 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/home/step-definitions/browse-listings.steps.ts @@ -0,0 +1,35 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { Ensure, equals } from '@serenity-js/assertions'; +import { actorCalled } from '@serenity-js/core'; +import type { ShareThriftWorld } from '../../../world.ts'; +import { CreateListing } from '../../listing/tasks/create-listing.ts'; +import { DiscoveryControls, ListingInDiscovery, SelectedCategory } from '../questions/home-page-content.ts'; +import { BrowseListings } from '../tasks/browse-listings.ts'; + +Given('{word} has created a published listing titled {string}', async function (this: ShareThriftWorld, actorName: string, title: string) { + await actorCalled(actorName).attemptsTo(CreateListing.with({ title, description: 'Portable projector for neighborhood movie nights', category: 'Electronics', location: 'Philadelphia, PA', isDraft: false })); +}); +When('{word} browses available listings', async function (this: ShareThriftWorld, actorName: string) { + await actorCalled(actorName).attemptsTo(BrowseListings.onTheHomePage()); +}); +When('{word} searches listings for {string}', async function (this: ShareThriftWorld, actorName: string, query: string) { + await actorCalled(actorName).attemptsTo(BrowseListings.matching(query)); +}); +When('{word} opens listing {string}', async function (this: ShareThriftWorld, actorName: string, title: string) { + await actorCalled(actorName).attemptsTo(BrowseListings.open(title)); +}); +When('{word} filters listings by {string}', async function (this: ShareThriftWorld, actorName: string, category: string) { + await actorCalled(actorName).attemptsTo(BrowseListings.inCategory(category)); +}); +Then('{word} should see listing search, category, and location controls', async function (this: ShareThriftWorld, actorName: string) { + await actorCalled(actorName).attemptsTo(Ensure.that(DiscoveryControls, equals(true))); +}); +Then('{word} should find the listing {string}', async function (this: ShareThriftWorld, actorName: string, title: string) { + await actorCalled(actorName).attemptsTo(Ensure.that(ListingInDiscovery(title), equals(true))); +}); +Then('{word} should see listing {string} details', async function (this: ShareThriftWorld, actorName: string, title: string) { + await actorCalled(actorName).attemptsTo(Ensure.that(ListingInDiscovery(title), equals(true))); +}); +Then('{word} should see {string} as the selected category', async function (this: ShareThriftWorld, actorName: string, category: string) { + await actorCalled(actorName).attemptsTo(Ensure.that(SelectedCategory(category), equals(true))); +}); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/home/tasks/browse-listings.ts b/packages/sthrift-verification/e2e-tests/src/contexts/home/tasks/browse-listings.ts new file mode 100644 index 000000000..dc4864795 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/home/tasks/browse-listings.ts @@ -0,0 +1,36 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Task } from '@serenity-js/core'; +import { HomePage } from '../pages/home.page.ts'; + +export class BrowseListings extends Task { + static onTheHomePage() { + return new BrowseListings('browses listings on the home page', 'visit'); + } + static matching(query: string) { + return new BrowseListings(`searches listings for "${query}"`, 'search', query); + } + static open(title: string) { + return new BrowseListings(`opens listing "${title}"`, 'open', title); + } + static inCategory(category: string) { + return new BrowseListings(`filters listings by "${category}"`, 'category', category); + } + + private constructor( + description: string, + private readonly action: 'visit' | 'search' | 'open' | 'category', + private readonly value?: string, + ) { + super(description); + } + + async performAs(actor: Actor): Promise { + const { page } = BrowseTheWeb.withActor(actor); + const home = new HomePage(new PlaywrightPageAdapter(page)); + if (this.action === 'visit') await home.visit(); + if (this.action === 'search') await home.searchFor(this.value ?? ''); + if (this.action === 'open') await home.openListing(this.value ?? ''); + if (this.action === 'category') await home.filterByCategory(this.value ?? ''); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/pages/listing-management.page.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/pages/listing-management.page.ts new file mode 100644 index 000000000..c6756c291 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/pages/listing-management.page.ts @@ -0,0 +1,20 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; + +export class ListingManagementPage { + constructor(private readonly adapter: PageAdapter) {} + get heading() { + return this.adapter.getByRole('heading', { name: 'My Listings' }); + } + get allListingsTab() { + return this.adapter.getByRole('tab', { name: 'All Listings' }); + } + get requestsTab() { + return this.adapter.getByRole('tab', { name: /Requests/ }); + } + get createListingHeading() { + return this.adapter.getByText('Create a Listing'); + } + async viewRequests(): Promise { + await this.requestsTab.click(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/questions/listing-management-content.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/questions/listing-management-content.ts new file mode 100644 index 000000000..5f6b23c6a --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/questions/listing-management-content.ts @@ -0,0 +1,16 @@ +import type { ElementHandle } from '@cellix/serenity-framework/pages'; +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { ListingManagementPage } from '../pages/listing-management.page.ts'; + +async function visible(actor: AnswersQuestions & UsesAbilities, element: (page: ListingManagementPage) => ElementHandle) { + const page = new ListingManagementPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); + const handle = element(page); + await handle.waitFor({ state: 'visible', timeout: 15_000 }); + return handle.isVisible(); +} + +export const ListingsDashboard = Question.about('the listings dashboard', (actor) => visible(actor, (page) => page.heading)); +export const IncomingRequestsTab = Question.about('the incoming requests tab', (actor) => visible(actor, (page) => page.requestsTab)); +export const CreateListingForm = Question.about('the create listing form', (actor) => visible(actor, (page) => page.createListingHeading)); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/step-definitions/manage-listings.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/step-definitions/manage-listings.steps.ts new file mode 100644 index 000000000..d16bdefbd --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/step-definitions/manage-listings.steps.ts @@ -0,0 +1,25 @@ +import { Then, When } from '@cucumber/cucumber'; +import { Ensure, equals } from '@serenity-js/assertions'; +import { actorCalled } from '@serenity-js/core'; +import type { ShareThriftWorld } from '../../../world.ts'; +import { CreateListingForm, IncomingRequestsTab, ListingsDashboard } from '../questions/listing-management-content.ts'; +import { OpenListingDashboard, StartCreatingListing, ViewIncomingRequests } from '../tasks/manage-listings.ts'; + +When('{word} starts a new listing', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(StartCreatingListing.fromNavigation()); +}); +When('{word} opens her listings dashboard', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(OpenListingDashboard.fromNavigation()); +}); +When('{word} views incoming listing requests', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(ViewIncomingRequests.fromDashboard()); +}); +Then('{word} should see the create listing form', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(Ensure.that(CreateListingForm, equals(true))); +}); +Then('{word} should see her listings', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(Ensure.that(ListingsDashboard, equals(true))); +}); +Then('{word} should see the requests tab', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(Ensure.that(IncomingRequestsTab, equals(true))); +}); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/tasks/manage-listings.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/tasks/manage-listings.ts new file mode 100644 index 000000000..081105ac8 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing-management/tasks/manage-listings.ts @@ -0,0 +1,44 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Task } from '@serenity-js/core'; +import { NavigationPage } from '../../../shared/pages/navigation.page.ts'; +import { ListingManagementPage } from '../pages/listing-management.page.ts'; + +export class OpenListingDashboard extends Task { + static fromNavigation() { + return new OpenListingDashboard(); + } + private constructor() { + super('opens My Listings from the navigation'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new NavigationPage(new PlaywrightPageAdapter(page)).open('My Listings'); + } +} +export class ViewIncomingRequests extends Task { + static fromDashboard() { + return new ViewIncomingRequests(); + } + private constructor() { + super('views incoming listing requests'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new ListingManagementPage(new PlaywrightPageAdapter(page)).viewRequests(); + } +} +export class StartCreatingListing extends Task { + static fromNavigation() { + return new StartCreatingListing(); + } + private constructor() { + super('starts creating a listing'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + const adapter = new PlaywrightPageAdapter(page); + await adapter.goto('/', { waitUntil: 'domcontentloaded' }); + await adapter.getByRole('button', { name: /Create a Listing/i }).click(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/messages/pages/messages.page.ts b/packages/sthrift-verification/e2e-tests/src/contexts/messages/pages/messages.page.ts new file mode 100644 index 000000000..868f5ac08 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/messages/pages/messages.page.ts @@ -0,0 +1,35 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; +export class MessagesPage { + constructor(private readonly adapter: PageAdapter) {} + get emptyConversationPrompt() { + return this.adapter.getByText('Select a conversation to start messaging.'); + } + get messageInput() { + return this.adapter.getByPlaceholder('Type a message...'); + } + get sendButton() { + return this.adapter.getByRole('button', { name: 'Send' }); + } + message(content: string) { + return this.adapter.getByText(content); + } + conversationFor(listingTitle: string) { + return this.adapter.getByText(`Listing: ${listingTitle}`); + } + async send(content: string): Promise { + await this.messageInput.waitFor({ state: 'visible', timeout: 15_000 }); + await this.messageInput.fill(content); + await this.sendButton.click(); + // The send mutation persists on the server and only renders the message once + // the server responds (the resolver uses `update`, not an optimistic response). + // Wait for the message to appear before returning so a subsequent navigation + // cannot abort the in-flight mutation before it reaches the backend. + await this.message(content).waitFor({ state: 'visible', timeout: 15_000 }); + } + async selectConversation(listingTitle: string): Promise { + const conversation = this.conversationFor(listingTitle); + await conversation.waitFor({ state: 'visible', timeout: 15_000 }); + await conversation.click(); + await this.messageInput.waitFor({ state: 'visible', timeout: 15_000 }); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/messages/questions/messaging-workspace.ts b/packages/sthrift-verification/e2e-tests/src/contexts/messages/questions/messaging-workspace.ts new file mode 100644 index 000000000..ab5d92a67 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/messages/questions/messaging-workspace.ts @@ -0,0 +1,17 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MessagesPage } from '../pages/messages.page.ts'; +export const MessagingWorkspace = Question.about('the messaging workspace', async (actor: AnswersQuestions & UsesAbilities) => { + const view = new MessagesPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); + await view.emptyConversationPrompt.waitFor({ state: 'visible', timeout: 15_000 }); + return view.emptyConversationPrompt.isVisible(); +}); + +export const SentMessage = (content: string) => + Question.about(`sent message "${content}"`, async (actor: AnswersQuestions & UsesAbilities) => { + const view = new MessagesPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page)); + const message = view.message(content); + await message.waitFor({ state: 'visible', timeout: 15_000 }); + return message.isVisible(); + }); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/messages/step-definitions/view-messages.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/messages/step-definitions/view-messages.steps.ts new file mode 100644 index 000000000..3c5ed9d8c --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/messages/step-definitions/view-messages.steps.ts @@ -0,0 +1,24 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { Ensure, equals } from '@serenity-js/assertions'; +import { actorCalled } from '@serenity-js/core'; +import type { ShareThriftWorld } from '../../../world.ts'; +import { SentMessage } from '../questions/messaging-workspace.ts'; +import { CreateConversation } from '../tasks/create-conversation.ts'; +import { OpenMessages, SendMessage } from '../tasks/open-messages.ts'; + +Given('{word} has a conversation about {string}', async function (this: ShareThriftWorld, name: string, title: string) { + await actorCalled(name).attemptsTo(CreateConversation.about(title)); +}); + +When('{word} opens her messages', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(OpenMessages.fromNavigation()); +}); +When('{word} sends the message {string}', async function (this: ShareThriftWorld, name: string, content: string) { + await actorCalled(name).attemptsTo(SendMessage.containing(content)); +}); +When('{word} leaves and reopens her messages', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(OpenMessages.afterVisitingHome()); +}); +Then('{word} should see the sent message {string}', async function (this: ShareThriftWorld, name: string, content: string) { + await actorCalled(name).attemptsTo(Ensure.that(SentMessage(content), equals(true))); +}); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/create-conversation.ts b/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/create-conversation.ts new file mode 100644 index 000000000..2d5caecc9 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/create-conversation.ts @@ -0,0 +1,82 @@ +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, notes, Task } from '@serenity-js/core'; + +export interface ConversationNotes { + conversationListingTitle: string; +} + +export class CreateConversation extends Task { + static about(listingTitle: string) { + return new CreateConversation(listingTitle); + } + private constructor(private readonly listingTitle: string) { + super(`has a conversation about "${listingTitle}"`); + } + async performAs(actor: Actor): Promise { + const { page } = BrowseTheWeb.withActor(actor); + const token = await page.evaluate(() => { + for (const storage of [sessionStorage, localStorage]) { + for (let index = 0; index < storage.length; index += 1) { + const raw = storage.getItem(storage.key(index) ?? ''); + if (!raw) continue; + try { + const user = JSON.parse(raw) as { access_token?: string; id_token?: string }; + if (user.access_token || user.id_token) return user.access_token ?? user.id_token; + } catch { + /* unrelated storage entry */ + } + } + } + return undefined; + }); + if (!token) throw new Error('Authenticated browser session did not contain an OAuth token'); + + const query = `query CurrentUserForConversation { currentUser { ... on PersonalUser { id } } }`; + const currentUserResponse = await page.request.post('https://data-access.sharethrift.localhost:1355/api/graphql', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { query }, + }); + const currentUserJson = (await currentUserResponse.json()) as { data?: { currentUser?: { id?: string } } }; + const userId = currentUserJson.data?.currentUser?.id; + if (!userId) throw new Error(`Could not resolve current user for conversation: ${JSON.stringify(currentUserJson)}`); + + const start = new Date(); + start.setDate(start.getDate() + 1); + const end = new Date(); + end.setDate(end.getDate() + 30); + const createListing = `mutation CreateMessagingFixtureListing($input: ItemListingCreateInput!) { createItemListing(input: $input) { status { success errorMessage } listing { id } } }`; + const listingResponse = await page.request.post('https://data-access.sharethrift.localhost:1355/api/graphql', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + query: createListing, + variables: { + input: { + title: this.listingTitle, + description: 'Messaging scenario fixture', + category: 'Other', + location: 'Philadelphia, PA', + sharingPeriodStart: start.toISOString(), + sharingPeriodEnd: end.toISOString(), + isDraft: true, + }, + }, + }, + }); + const listingJson = (await listingResponse.json()) as { data?: { createItemListing?: { status?: { success?: boolean; errorMessage?: string }; listing?: { id?: string } } } }; + const listingId = listingJson.data?.createItemListing?.listing?.id; + if (!listingJson.data?.createItemListing?.status?.success || !listingId) { + throw new Error(`Listing fixture setup failed: ${listingJson.data?.createItemListing?.status?.errorMessage ?? JSON.stringify(listingJson)}`); + } + + const mutation = `mutation CreateE2EConversation($input: ConversationCreateInput!) { createConversation(input: $input) { status { success errorMessage } conversation { id } } }`; + const response = await page.request.post('https://data-access.sharethrift.localhost:1355/api/graphql', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { query: mutation, variables: { input: { listingId, sharerId: userId, reserverId: userId } } }, + }); + const json = (await response.json()) as { data?: { createConversation?: { status?: { success?: boolean; errorMessage?: string } } } }; + if (!json.data?.createConversation?.status?.success) { + throw new Error(`Conversation setup failed: ${json.data?.createConversation?.status?.errorMessage ?? JSON.stringify(json)}`); + } + await actor.attemptsTo(notes().set('conversationListingTitle', this.listingTitle)); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/open-messages.ts b/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/open-messages.ts new file mode 100644 index 000000000..3f64a38c9 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/messages/tasks/open-messages.ts @@ -0,0 +1,46 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { NavigationPage } from '../../../shared/pages/navigation.page.ts'; +import { MessagesPage } from '../pages/messages.page.ts'; +import type { ConversationNotes } from './create-conversation.ts'; +export class OpenMessages extends Task { + static fromNavigation() { + return new OpenMessages(); + } + static afterVisitingHome() { + return Task.where('#actor leaves and reopens Messages', new NavigateHome(), new OpenMessages()); + } + private constructor() { + super('opens Messages from the navigation'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new NavigationPage(new PlaywrightPageAdapter(page)).open('Messages'); + const listingTitle = await actor.answer(notes().get('conversationListingTitle')); + await new MessagesPage(new PlaywrightPageAdapter(page)).selectConversation(listingTitle); + } +} + +class NavigateHome extends Task { + constructor() { + super('navigates home'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new NavigationPage(new PlaywrightPageAdapter(page)).open('Home'); + } +} + +export class SendMessage extends Task { + static containing(content: string) { + return new SendMessage(content); + } + private constructor(private readonly content: string) { + super(`sends the message "${content}"`); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new MessagesPage(new PlaywrightPageAdapter(page)).send(this.content); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/pages/reservations.page.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/pages/reservations.page.ts new file mode 100644 index 000000000..ff8f9990e --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/pages/reservations.page.ts @@ -0,0 +1,16 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; +export class ReservationsPage { + constructor(private readonly adapter: PageAdapter) {} + get heading() { + return this.adapter.getByRole('heading', { name: 'My Reservations' }); + } + get activeTab() { + return this.adapter.getByRole('tab', { name: 'Active Reservations' }); + } + get historyTab() { + return this.adapter.getByRole('tab', { name: 'Reservation History' }); + } + async viewHistory() { + await this.historyTab.click(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/step-definitions/manage-reservations.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/step-definitions/manage-reservations.steps.ts new file mode 100644 index 000000000..f98d58f49 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/step-definitions/manage-reservations.steps.ts @@ -0,0 +1,24 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { Then, When } from '@cucumber/cucumber'; +import { actorCalled } from '@serenity-js/core'; +import type { ShareThriftWorld } from '../../../world.ts'; +import { ReservationsPage } from '../pages/reservations.page.ts'; +import { OpenReservations, ViewReservationHistory } from '../tasks/review-reservations.ts'; + +When('{word} opens her reservations', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(OpenReservations.fromNavigation()); +}); +When('{word} views reservation history', async function (this: ShareThriftWorld, name: string) { + await actorCalled(name).attemptsTo(ViewReservationHistory.fromActiveReservations()); +}); +Then('{word} should see active reservations', async function (this: ShareThriftWorld, name: string) { + const { page } = BrowseTheWeb.withActor(actorCalled(name)); + const view = new ReservationsPage(new PlaywrightPageAdapter(page)); + await view.activeTab.waitFor({ state: 'visible', timeout: 15_000 }); +}); +Then('{word} should see reservation history', async function (this: ShareThriftWorld, name: string) { + const { page } = BrowseTheWeb.withActor(actorCalled(name)); + const view = new ReservationsPage(new PlaywrightPageAdapter(page)); + await view.historyTab.waitFor({ state: 'visible', timeout: 15_000 }); +}); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/tasks/review-reservations.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/tasks/review-reservations.ts new file mode 100644 index 000000000..8ca9d6293 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-management/tasks/review-reservations.ts @@ -0,0 +1,29 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Task } from '@serenity-js/core'; +import { NavigationPage } from '../../../shared/pages/navigation.page.ts'; +import { ReservationsPage } from '../pages/reservations.page.ts'; +export class OpenReservations extends Task { + static fromNavigation() { + return new OpenReservations(); + } + private constructor() { + super('opens My Reservations'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new NavigationPage(new PlaywrightPageAdapter(page)).open('My Reservations'); + } +} +export class ViewReservationHistory extends Task { + static fromActiveReservations() { + return new ViewReservationHistory(); + } + private constructor() { + super('views reservation history'); + } + async performAs(actor: Actor) { + const { page } = BrowseTheWeb.withActor(actor); + await new ReservationsPage(new PlaywrightPageAdapter(page)).viewHistory(); + } +} diff --git a/packages/sthrift-verification/e2e-tests/src/infrastructure.ts b/packages/sthrift-verification/e2e-tests/src/infrastructure.ts index 6454b8230..217fae76a 100644 --- a/packages/sthrift-verification/e2e-tests/src/infrastructure.ts +++ b/packages/sthrift-verification/e2e-tests/src/infrastructure.ts @@ -1,17 +1,22 @@ import { E2EInfrastructure } from '@cellix/serenity-framework/infrastructure/e2e'; -import { testApiServer, testAzuriteServer, testMongoServer, testOAuth2Server, uiShareThriftPortalServer } from './servers/index.ts'; +import { testApiServer, testAzuriteServer, testMessagingServer, testMongoServer, testOAuth2Server, uiShareThriftPortalServer } from './servers/index.ts'; import { cleanupTestEnvironment, initTestEnvironment } from './shared/environment/test-environment.ts'; import { performOAuth2Login } from './shared/support/oauth2-login.ts'; const runtime = E2EInfrastructure.create({ - browserContextOptions: { ignoreHTTPSErrors: true }, + browserContextOptions: { + ignoreHTTPSErrors: true, + screen: { width: 1440, height: 900 }, + viewport: { width: 1440, height: 900 }, + }, cleanupEnvironment: cleanupTestEnvironment, setupEnvironment: initTestEnvironment, }) .addServer('mongo', testMongoServer) .addServer('azurite', testAzuriteServer) .addServer('auth', testOAuth2Server) - .addServer('api', testApiServer, { dependsOn: ['mongo', 'azurite', 'auth'] }) + .addServer('messaging', testMessagingServer) + .addServer('api', testApiServer, { dependsOn: ['mongo', 'azurite', 'auth', 'messaging'] }) .addUiPortal('sharethrift', uiShareThriftPortalServer, { dependsOn: ['api', 'auth'], }) diff --git a/packages/sthrift-verification/e2e-tests/src/scenarios/account/view-account-settings.feature b/packages/sthrift-verification/e2e-tests/src/scenarios/account/view-account-settings.feature new file mode 100644 index 000000000..8965ef74f --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/scenarios/account/view-account-settings.feature @@ -0,0 +1,14 @@ +@e2e +Feature: View Account Settings + + As a ShareThrift user + I want to access my account settings + So that I can maintain my personal information + + Background: + Given Alice is an authenticated user + + Scenario: Edit profile details + When Alice opens her account settings + And Alice changes her first name to "Alicia" + Then Alice should see the saved first name "Alicia" diff --git a/packages/sthrift-verification/e2e-tests/src/scenarios/home/browse-listings.feature b/packages/sthrift-verification/e2e-tests/src/scenarios/home/browse-listings.feature new file mode 100644 index 000000000..0e6d4714b --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/scenarios/home/browse-listings.feature @@ -0,0 +1,26 @@ +@e2e +Feature: Browse Listings + + As a ShareThrift user + I want to discover available items + So that I can find something useful to borrow + + Background: + Given Alice is an authenticated user + + Scenario: Browse listings using discovery controls + When Alice browses available listings + Then Alice should see listing search, category, and location controls + + Scenario: Filter discovery by category + When Alice browses available listings + And Alice filters listings by "Electronics" + Then Alice should see "Electronics" as the selected category + + Scenario: Find and open a published listing + Given Alice has created a published listing titled "E2E Trail Projector" + When Alice browses available listings + And Alice searches listings for "E2E Trail Projector" + Then Alice should find the listing "E2E Trail Projector" + When Alice opens listing "E2E Trail Projector" + Then Alice should see listing "E2E Trail Projector" details diff --git a/packages/sthrift-verification/e2e-tests/src/scenarios/listing/manage-listings.feature b/packages/sthrift-verification/e2e-tests/src/scenarios/listing/manage-listings.feature new file mode 100644 index 000000000..41b905b3e --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/scenarios/listing/manage-listings.feature @@ -0,0 +1,19 @@ +@e2e +Feature: Manage Listings + + As a ShareThrift sharer + I want to manage listings and incoming requests + So that I can share my items responsibly + + Background: + Given Alice is an authenticated user + + Scenario: Start creating a listing from the application header + When Alice starts a new listing + Then Alice should see the create listing form + + Scenario: Review listings and incoming requests + When Alice opens her listings dashboard + Then Alice should see her listings + When Alice views incoming listing requests + Then Alice should see the requests tab diff --git a/packages/sthrift-verification/e2e-tests/src/scenarios/messages/view-messages.feature b/packages/sthrift-verification/e2e-tests/src/scenarios/messages/view-messages.feature new file mode 100644 index 000000000..82dcc9707 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/scenarios/messages/view-messages.feature @@ -0,0 +1,22 @@ +@e2e +Feature: Send Messages + + As a ShareThrift user + I want to access my conversations + So that I can coordinate sharing with other members + + Background: + Given Alice is an authenticated user + + Scenario: Send a message about a listing + Given Alice has a conversation about "E2E Message Projector" + When Alice opens her messages + And Alice sends the message "Is the projector available Saturday?" + Then Alice should see the sent message "Is the projector available Saturday?" + + Scenario: A sent message remains in the conversation + Given Alice has a conversation about "E2E Camping Tent" + When Alice opens her messages + And Alice sends the message "Could I pick this up after work?" + And Alice leaves and reopens her messages + Then Alice should see the sent message "Could I pick this up after work?" diff --git a/packages/sthrift-verification/e2e-tests/src/scenarios/reservation-request/manage-reservations.feature b/packages/sthrift-verification/e2e-tests/src/scenarios/reservation-request/manage-reservations.feature new file mode 100644 index 000000000..3f76c242c --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/scenarios/reservation-request/manage-reservations.feature @@ -0,0 +1,15 @@ +@e2e +Feature: Manage Reservations + + As a ShareThrift borrower + I want to review current and previous reservations + So that I can keep track of borrowed items + + Background: + Given Alice is an authenticated user + + Scenario: Review active and historical reservations + When Alice opens her reservations + Then Alice should see active reservations + When Alice views reservation history + Then Alice should see reservation history diff --git a/packages/sthrift-verification/e2e-tests/src/servers/index.ts b/packages/sthrift-verification/e2e-tests/src/servers/index.ts index f0dcc9fc4..6487e04f4 100644 --- a/packages/sthrift-verification/e2e-tests/src/servers/index.ts +++ b/packages/sthrift-verification/e2e-tests/src/servers/index.ts @@ -1,5 +1,6 @@ export { testApiServer } from './test-api-server.ts'; export { testAzuriteServer } from './test-azurite-server.ts'; +export { testMessagingServer } from './test-messaging-server.ts'; export { testMongoServer } from './test-mongo-server.ts'; export { testOAuth2Server } from './test-oauth2-server.ts'; export { uiShareThriftPortalServer } from './ui-sharethrift-portal-server.ts'; diff --git a/packages/sthrift-verification/e2e-tests/src/servers/test-messaging-server.ts b/packages/sthrift-verification/e2e-tests/src/servers/test-messaging-server.ts new file mode 100644 index 000000000..9d437e9c5 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/servers/test-messaging-server.ts @@ -0,0 +1,12 @@ +import { ProcessTestServer } from '@cellix/serenity-framework/servers'; +import { appPaths } from '../shared/environment/app-paths.ts'; +import { buildUrl } from '../shared/environment/test-environment.ts'; + +export const testMessagingServer = new ProcessTestServer({ + cwd: appPaths.messagingMockDir, + executable: 'pnpm', + readyMarker: 'Mock Messaging Server listening', + serverName: 'TestMessagingServer', + spawnArgs: ['run', 'dev'], + url: buildUrl('mock-messaging.sharethrift.localhost'), +}); diff --git a/packages/sthrift-verification/e2e-tests/src/shared/environment/app-paths.ts b/packages/sthrift-verification/e2e-tests/src/shared/environment/app-paths.ts index c37b26a11..624e97027 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/environment/app-paths.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/environment/app-paths.ts @@ -5,6 +5,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../.. export const appPaths = { apiDir: resolve(root, 'apps/api'), mongodbMemoryMockDir: resolve(root, 'apps/server-mongodb-memory-mock'), + messagingMockDir: resolve(root, 'apps/server-messaging-mock'), oauth2MockDir: resolve(root, 'apps/server-oauth2-mock'), uiShareThriftDir: resolve(root, 'apps/ui-sharethrift'), }; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/navigation.page.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/navigation.page.ts new file mode 100644 index 000000000..e1d0ab199 --- /dev/null +++ b/packages/sthrift-verification/e2e-tests/src/shared/pages/navigation.page.ts @@ -0,0 +1,28 @@ +import type { PageAdapter } from '@cellix/serenity-framework/pages'; + +export class NavigationPage { + constructor(private readonly adapter: PageAdapter) {} + + async open(label: string): Promise { + await this.adapter.goto('/', { waitUntil: 'domcontentloaded' }); + const selectors: Record = { + Home: '.ant-menu-item:visible:has-text("Home")', + 'My Listings': '.ant-menu-item:visible:has-text("My Listings")', + 'My Reservations': '.ant-menu-item:visible:has-text("My Reservations")', + Messages: '.ant-menu-item:visible:has-text("Messages")', + Account: '.ant-menu-submenu-title:visible:has-text("Account")', + Profile: '.ant-menu-item:visible:has-text("Profile")', + Settings: '.ant-menu-item:visible:has-text("Settings")', + }; + const home = this.adapter.locator(selectors.Home ?? ''); + await home.waitFor({ state: 'visible', timeout: 30_000 }); + if (label === 'Profile' || label === 'Settings') { + await this.adapter.locator(selectors.Account ?? '').click(); + } + const selector = selectors[label]; + if (!selector) throw new Error(`Unsupported navigation destination: ${label}`); + const destination = this.adapter.locator(selector); + await destination.waitFor({ state: 'visible', timeout: 30_000 }); + await destination.click(); + } +} diff --git a/packages/sthrift/data-sources-mongoose-models/src/models/user/personal-user.model.ts b/packages/sthrift/data-sources-mongoose-models/src/models/user/personal-user.model.ts index 67abbaaec..531d7f601 100644 --- a/packages/sthrift/data-sources-mongoose-models/src/models/user/personal-user.model.ts +++ b/packages/sthrift/data-sources-mongoose-models/src/models/user/personal-user.model.ts @@ -1,16 +1,10 @@ -import { - type Model, - Schema, - type SchemaDefinition, - type Types, -} from 'mongoose'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; -import { type User, type UserModelType, userOptions } from './user.model.ts'; +import { type Model, Schema, type SchemaDefinition, type Types } from 'mongoose'; import { Patterns } from '../../patterns.ts'; +import { type User, type UserModelType, userOptions } from './user.model.ts'; // Location -export interface PersonalUserAccountProfileLocation - extends MongooseSeedwork.NestedPath { +export interface PersonalUserAccountProfileLocation extends MongooseSeedwork.NestedPath { address1: string; address2: string | null; city: string; @@ -18,15 +12,14 @@ export interface PersonalUserAccountProfileLocation country: string; zipCode: string; } -export const PersonalUserAccountProfileLocationType: SchemaDefinition = - { - address1: { type: String, required: true }, - address2: { type: String, required: false }, - city: { type: String, required: true }, - state: { type: String, required: true }, - country: { type: String, required: true }, - zipCode: { type: String, required: true }, - }; +export const PersonalUserAccountProfileLocationType: SchemaDefinition = { + address1: { type: String, required: true }, + address2: { type: String, required: false }, + city: { type: String, required: true }, + state: { type: String, required: true }, + country: { type: String, required: true }, + zipCode: { type: String, required: true }, +}; export const PaymentState = { FAILED: 'FAILED', @@ -40,16 +33,14 @@ export const SubscriptionStatus = { FAILED: 'FAILED', PENDING: 'PENDING', }; -export interface PersonalUserAccountProfileBillingSubscription - extends MongooseSeedwork.NestedPath { +export interface PersonalUserAccountProfileBillingSubscription extends MongooseSeedwork.NestedPath { subscriptionId: string; planCode: string; status: string; startDate: Date; } -export interface PersonalUserAccountProfileBillingTransactions - extends MongooseSeedwork.SubdocumentBase { +export interface PersonalUserAccountProfileBillingTransactions extends MongooseSeedwork.SubdocumentBase { transactionId: string; amount: number; referenceId: string; @@ -58,89 +49,79 @@ export interface PersonalUserAccountProfileBillingTransactions errorMessage: string | null; } -export interface PersonalUserAccountProfileBilling - extends MongooseSeedwork.NestedPath { +export interface PersonalUserAccountProfileBilling extends MongooseSeedwork.NestedPath { cybersourceCustomerId: string; subscription: PersonalUserAccountProfileBillingSubscription; transactions: Types.DocumentArray; } -export const PersonalUserAccountProfileBillingSubscriptionType: SchemaDefinition = - { - subscriptionId: { type: String, required: true }, - planCode: { type: String, required: true }, - status: { - type: String, - required: true, - enum: [ - SubscriptionStatus.PENDING, - SubscriptionStatus.ACTIVE, - SubscriptionStatus.FAILED, - ], - }, - startDate: { type: Date, required: true, default: Date.now }, - }; +export const PersonalUserAccountProfileBillingSubscriptionType: SchemaDefinition = { + subscriptionId: { type: String, required: true }, + planCode: { type: String, required: true }, + status: { + type: String, + required: true, + enum: [ + SubscriptionStatus.PENDING, + SubscriptionStatus.ACTIVE, + SubscriptionStatus.FAILED, + // Existing payment-provider records use lowercase status values. + 'pending', + 'active', + 'failed', + ], + }, + startDate: { type: Date, required: true, default: Date.now }, +}; -export const PersonalUserAccountProfileBillingTransactionsSchema = new Schema< - PersonalUserAccountProfileBillingTransactions, - Model, - PersonalUserAccountProfileBillingTransactions ->({ +export const PersonalUserAccountProfileBillingTransactionsSchema = new Schema, PersonalUserAccountProfileBillingTransactions>({ transactionId: { type: String, required: true }, amount: { type: Number, required: true }, referenceId: { type: String, required: true }, status: { type: String, required: true, - enum: [ - PaymentState.FAILED, - PaymentState.PENDING, - PaymentState.REFUNDED, - PaymentState.SUCCEEDED, - ], + enum: [PaymentState.FAILED, PaymentState.PENDING, PaymentState.REFUNDED, PaymentState.SUCCEEDED], }, completedAt: { type: Date, required: false }, errorMessage: { type: String, required: false }, }); -export const PersonalUserAccountProfileBillingType: SchemaDefinition = - { - cybersourceCustomerId: { type: String, required: false }, - subscription: { - type: PersonalUserAccountProfileBillingSubscriptionType, - required: false, - ...MongooseSeedwork.NestedPathOptions, - }, - transactions: { - type: [PersonalUserAccountProfileBillingTransactionsSchema], - required: false, - }, - }; +export const PersonalUserAccountProfileBillingType: SchemaDefinition = { + cybersourceCustomerId: { type: String, required: false }, + subscription: { + type: PersonalUserAccountProfileBillingSubscriptionType, + required: false, + ...MongooseSeedwork.NestedPathOptions, + }, + transactions: { + type: [PersonalUserAccountProfileBillingTransactionsSchema], + required: false, + }, +}; // Profile -export interface PersonalUserAccountProfile - extends MongooseSeedwork.NestedPath { +export interface PersonalUserAccountProfile extends MongooseSeedwork.NestedPath { firstName: string; lastName: string; aboutMe: string; location: PersonalUserAccountProfileLocation; billing: PersonalUserAccountProfileBilling; } -export const PersonalUserAccountProfileType: SchemaDefinition = - { - firstName: { type: String, required: true }, - lastName: { type: String, required: true }, - aboutMe: { type: String, required: false }, - location: { - type: PersonalUserAccountProfileLocationType, - required: false, - ...MongooseSeedwork.NestedPathOptions, - }, - billing: { - type: PersonalUserAccountProfileBillingType, - required: false, - ...MongooseSeedwork.NestedPathOptions, - }, - }; +export const PersonalUserAccountProfileType: SchemaDefinition = { + firstName: { type: String, required: true }, + lastName: { type: String, required: true }, + aboutMe: { type: String, required: false }, + location: { + type: PersonalUserAccountProfileLocationType, + required: false, + ...MongooseSeedwork.NestedPathOptions, + }, + billing: { + type: PersonalUserAccountProfileBillingType, + required: false, + ...MongooseSeedwork.NestedPathOptions, + }, +}; // Account export interface PersonalUserAccount extends MongooseSeedwork.NestedPath { @@ -153,13 +134,7 @@ export const PersonalUserAccountType: SchemaDefinition = { accountType: { type: String, required: false, - enum: [ - 'non-verified-personal', - 'verified-personal', - 'verified-personal-plus', - 'business', - 'enterprise', - ], + enum: ['non-verified-personal', 'verified-personal', 'verified-personal-plus', 'business', 'enterprise'], }, email: { type: String, @@ -190,11 +165,7 @@ export interface PersonalUser extends User { updatedAt: Date; } -const PersonalUserSchema = new Schema< - PersonalUser, - Model, - PersonalUser ->( +const PersonalUserSchema = new Schema, PersonalUser>( { isBlocked: { type: Boolean, required: false, default: false }, account: { diff --git a/packages/sthrift/ui-sharethrift-route-root/src/components/pages/account/pages/settings/components/settings-view.container.tsx b/packages/sthrift/ui-sharethrift-route-root/src/components/pages/account/pages/settings/components/settings-view.container.tsx index 2d1c05299..917899983 100644 --- a/packages/sthrift/ui-sharethrift-route-root/src/components/pages/account/pages/settings/components/settings-view.container.tsx +++ b/packages/sthrift/ui-sharethrift-route-root/src/components/pages/account/pages/settings/components/settings-view.container.tsx @@ -3,34 +3,20 @@ import { ComponentQueryLoader } from '@sthrift/ui-shared'; import { message } from 'antd'; import type React from 'react'; import { useState } from 'react'; -import { - HomeAccountSettingsViewContainerCurrentUserDocument, - HomeAccountSettingsViewContainerUpdatePersonalUserDocument, -} from '../../../../../../generated.tsx'; +import { HomeAccountSettingsViewContainerCurrentUserDocument, HomeAccountSettingsViewContainerUpdatePersonalUserDocument } from '../../../../../../generated.tsx'; import { SettingsView } from '../pages/settings-view.tsx'; -import type { - CurrentUserSettingsQueryData, - SettingsUser, - SectionFormValues, -} from './settings-view.types.ts'; +import type { CurrentUserSettingsQueryData, SectionFormValues, SettingsUser } from './settings-view.types.ts'; function SettingsViewLoader() { - const { - data: userData, - loading: userLoading, - error: userError, - } = useQuery( - HomeAccountSettingsViewContainerCurrentUserDocument, - ); + const { data: userData, loading: userLoading, error: userError } = useQuery(HomeAccountSettingsViewContainerCurrentUserDocument); - const [updateUserMutation, { loading: updateLoading, error: updateError }] = - useMutation(HomeAccountSettingsViewContainerUpdatePersonalUserDocument, { - onError: (err) => { - console.error('[SettingsView] update mutation error', err); - const msg = err?.message || 'Update failed'; - message.error(msg); - }, - }); + const [updateUserMutation, { loading: updateLoading, error: updateError }] = useMutation(HomeAccountSettingsViewContainerUpdatePersonalUserDocument, { + onError: (err) => { + console.error('[SettingsView] update mutation error', err); + const msg = err?.message || 'Update failed'; + message.error(msg); + }, + }); const [isSavingSection, setIsSavingSection] = useState(false); @@ -43,12 +29,7 @@ function SettingsViewLoader() { //navigate(newPath); }; - type EditableSection = - | 'profile' - | 'location' - | 'plan' - | 'billing' - | 'password'; + type EditableSection = 'profile' | 'location' | 'plan' | 'billing' | 'password'; // Profile type alias for clarity (only PersonalUser has nested profile) type UserProfile = { firstName: string; @@ -69,50 +50,19 @@ function SettingsViewLoader() { }; // Helper to construct the next profile given the section being edited - const buildNextProfile = ( - section: EditableSection, - values: SectionFormValues, - base: UserProfile, - ): UserProfile => { + const buildNextProfile = (section: EditableSection, values: SectionFormValues, base: UserProfile): UserProfile => { const isProfile = section === 'profile'; const isLocation = section === 'location'; - const isBilling = section === 'billing'; return { - firstName: isProfile - ? (values['firstName'] ?? base.firstName) - : base.firstName, - lastName: isProfile - ? (values['lastName'] ?? base.lastName) - : base.lastName, + firstName: isProfile ? (values['firstName'] ?? base.firstName) : base.firstName, + lastName: isProfile ? (values['lastName'] ?? base.lastName) : base.lastName, location: { - address1: isLocation - ? (values['address1'] ?? base.location.address1 ?? '') - : base.location.address1, - address2: isLocation - ? (values['address2'] ?? base.location.address2 ?? '') - : base.location.address2, - city: isLocation - ? (values['city'] ?? base.location.city ?? '') - : base.location.city, - state: isLocation - ? (values['state'] ?? base.location.state ?? '') - : base.location.state, - country: isLocation - ? (values['country'] ?? base.location.country ?? '') - : base.location.country, - zipCode: isLocation - ? (values['zipCode'] ?? base.location.zipCode ?? '') - : base.location.zipCode, - }, - billing: { - subscriptionId: isBilling - ? (values['subscriptionId'] ?? base.billing?.subscriptionId ?? '') - : base.billing?.subscriptionId, - cybersourceCustomerId: isBilling - ? (values['cybersourceCustomerId'] ?? - base.billing?.cybersourceCustomerId ?? - '') - : base.billing?.cybersourceCustomerId, + address1: isLocation ? (values['address1'] ?? base.location.address1 ?? '') : base.location.address1, + address2: isLocation ? (values['address2'] ?? base.location.address2 ?? '') : base.location.address2, + city: isLocation ? (values['city'] ?? base.location.city ?? '') : base.location.city, + state: isLocation ? (values['state'] ?? base.location.state ?? '') : base.location.state, + country: isLocation ? (values['country'] ?? base.location.country ?? '') : base.location.country, + zipCode: isLocation ? (values['zipCode'] ?? base.location.zipCode ?? '') : base.location.zipCode, }, ...(isProfile && { aboutMe: values['aboutMe'] ?? base.aboutMe }), }; @@ -126,11 +76,7 @@ function SettingsViewLoader() { return false; }; - const handlePersonalUserSave = async ( - section: EditableSection, - values: SectionFormValues, - user: CurrentUserSettingsQueryData['currentUser'], - ) => { + const handlePersonalUserSave = async (section: EditableSection, values: SectionFormValues, user: CurrentUserSettingsQueryData['currentUser']) => { if (updateLoading) { setIsSavingSection(false); return; @@ -139,14 +85,8 @@ function SettingsViewLoader() { try { const base = user.account.profile; const nextProfile = buildNextProfile(section, values, base); - const username = - section === 'profile' - ? (values['username'] ?? user.account.username) - : user.account.username; - const accountType = - section === 'plan' - ? (values['accountType'] ?? user.account.accountType) - : user.account.accountType; + const username = section === 'profile' ? (values['username'] ?? user.account.username) : user.account.username; + const accountType = section === 'plan' ? (values['accountType'] ?? user.account.accountType) : user.account.accountType; const result = await updateUserMutation({ variables: { @@ -159,9 +99,7 @@ function SettingsViewLoader() { }, }, }, - refetchQueries: [ - { query: HomeAccountSettingsViewContainerCurrentUserDocument }, - ], + refetchQueries: [{ query: HomeAccountSettingsViewContainerCurrentUserDocument }], }); if (!result.data?.personalUserUpdate) { @@ -177,10 +115,7 @@ function SettingsViewLoader() { } }; - const handleSaveSection = async ( - section: EditableSection, - values: SectionFormValues, - ) => { + const handleSaveSection = async (section: EditableSection, values: SectionFormValues) => { if (!userData?.currentUser) return; const user = userData.currentUser; @@ -221,9 +156,7 @@ function SettingsViewLoader() { country: user.account.profile.location.country ?? '', zipCode: user.account.profile.location.zipCode ?? '', }, - billing: 'billing' in user.account.profile - ? user.account.profile.billing - : undefined, + billing: 'billing' in user.account.profile ? user.account.profile.billing : undefined, createdAt: user.createdAt, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8ca8246f..ca33cbb5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,10 +36,13 @@ overrides: '@apollo/server': '>=5.5.0' '@types/node': ^24.10.7 axios: 1.16.0 + body-parser@2.2.2: 2.3.0 brace-expansion@5.0.4: 5.0.5 express@4: '>=4.22.2' follow-redirects: 1.16.0 happy-dom@20.8.4: 20.8.9 + js-yaml@3.14.2: 3.15.0 + js-yaml@4.1.1: 4.3.0 lodash: ^4.18.1 minimatch: '>=10.2.3' node-forge@1.3.3: 1.4.0 @@ -47,7 +50,7 @@ overrides: picomatch@2.3.1: 2.3.2 picomatch@4.0.3: 4.0.4 postcss: 8.5.10 - protobufjs: 7.5.5 + protobufjs: 7.6.5 qs: '>=6.14.2' serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 @@ -5470,12 +5473,21 @@ packages: '@protobufjs/codegen@2.0.4': resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + '@protobufjs/fetch@1.1.0': resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} @@ -5491,6 +5503,9 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rc-component/async-validator@5.1.0': resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} engines: {node: '>=14.x'} @@ -7051,8 +7066,8 @@ packages: bl@6.1.6: resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} bonjour-service@1.3.0: @@ -7510,6 +7525,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + continuation-local-storage@3.2.1: resolution: {integrity: sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==} @@ -9502,12 +9521,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsbi@4.3.2: @@ -11225,8 +11244,8 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - protobufjs@7.5.5: - resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -12819,6 +12838,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -13737,7 +13760,7 @@ snapshots: '@apollo/utils.withrequired': 3.0.0 '@graphql-tools/schema': 10.0.31(graphql@16.13.1) async-retry: 1.3.3 - body-parser: 2.2.2 + body-parser: 2.3.0 content-type: 1.0.5 cors: 2.8.6 finalhandler: 2.1.1 @@ -15788,7 +15811,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.4 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -16242,7 +16265,7 @@ snapshots: '@docusaurus/utils-common': 3.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fs-extra: 11.3.4 joi: 17.13.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 tslib: 2.8.1 transitivePeerDependencies: @@ -16267,7 +16290,7 @@ snapshots: globby: 11.1.0 gray-matter: 4.0.3 jiti: 1.21.7 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 micromatch: 4.0.8 p-queue: 6.6.2 @@ -16432,7 +16455,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 10.2.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -16916,7 +16939,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 jose: 5.10.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 scuid: 1.1.0 tslib: 2.8.1 @@ -17048,7 +17071,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.5.5 + protobufjs: 7.6.5 yargs: 17.7.2 '@hapi/hoek@9.3.0': {} @@ -17563,7 +17586,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - protobufjs: 7.5.5 + protobufjs: 7.6.5 '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -17902,13 +17925,21 @@ snapshots: '@protobufjs/codegen@2.0.4': {} + '@protobufjs/codegen@2.0.5': {} + '@protobufjs/eventemitter@1.1.0': {} + '@protobufjs/eventemitter@1.1.1': {} + '@protobufjs/fetch@1.1.0': dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/inquire': 1.1.0 + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/float@1.0.2': {} '@protobufjs/inquire@1.1.0': {} @@ -17919,6 +17950,8 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@protobufjs/utf8@1.1.2': {} + '@rc-component/async-validator@5.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -19808,17 +19841,17 @@ snapshots: inherits: 2.0.4 readable-stream: 4.7.0 - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.0 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -20334,6 +20367,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + continuation-local-storage@3.2.1: dependencies: async-listener: 0.6.10 @@ -20385,7 +20420,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.8.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -21236,7 +21271,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -21799,7 +21834,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.14.2 + js-yaml: 3.15.0 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -22587,12 +22622,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -24735,18 +24770,17 @@ snapshots: proto-list@1.2.4: {} - protobufjs@7.5.5: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 + '@protobufjs/utf8': 1.1.2 '@types/node': 24.12.0 long: 5.3.2 @@ -26640,6 +26674,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4