From 1f5d97732eceec8974ed9add7ea11ffa0ccbc2c2 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 2 Apr 2026 13:54:19 -0400 Subject: [PATCH 1/7] initial coverage setup, analysis --- package.json | 3 +- .../acceptance-tests/.c8rc.json | 35 +++++ .../acceptance-tests/.gitignore | 3 + .../acceptance-tests/package.json | 22 ++- .../step-definitions/create-listing.steps.ts | 3 + .../listing/tasks/ui/create-listing.ts | 103 +++++++++++++ .../create-reservation-request.steps.ts | 8 +- .../tasks/ui/create-reservation-request.ts | 131 ++++++++++++++++ .../src/shared/support/cast.ts | 4 +- .../shared/support/ui/asset-loader-hooks.mjs | 36 +++++ .../src/shared/support/ui/jsdom-setup.ts | 144 ++++++++++++++++++ .../src/shared/support/ui/react-render.tsx | 67 ++++++++ .../support/ui/register-asset-loader.ts | 5 + .../acceptance-tests/src/world.ts | 7 +- .../acceptance-tests/turbo.json | 5 + pnpm-lock.yaml | 120 ++++++++++++++- 16 files changed, 686 insertions(+), 10 deletions(-) create mode 100644 packages/sthrift-verification/acceptance-tests/.c8rc.json create mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts diff --git a/package.json b/package.json index 1836548e5..41822bda3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "test:acceptance:session": "turbo run test:session:graphql --filter=@sthrift-verification/acceptance-tests", "test:acceptance:session:graphql": "turbo run test:session:graphql --filter=@sthrift-verification/acceptance-tests", "test:acceptance:session:mongodb": "turbo run test:session:mongodb --filter=@sthrift-verification/acceptance-tests", - "test:acceptance:e2e": "turbo run test:e2e --filter=@sthrift-verification/e2e-tests", + "test:acceptance:ui": "turbo run test:ui --filter=@sthrift-verification/acceptance-tests", + "test:e2e": "turbo run test:e2e --filter=@sthrift-verification/e2e-tests", "test:coverage": "turbo run test:coverage:ui && turbo run test:coverage:node && turbo run test:arch", "test:coverage:node": "turbo run test:coverage:node", "test:coverage:ui": "turbo run test:coverage:ui", diff --git a/packages/sthrift-verification/acceptance-tests/.c8rc.json b/packages/sthrift-verification/acceptance-tests/.c8rc.json new file mode 100644 index 000000000..0faa5cfa2 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/.c8rc.json @@ -0,0 +1,35 @@ +{ + "all": true, + "reporter": ["lcov"], + "reportsDirectory": "coverage-c8", + "tempDirectory": ".c8-output", + "src": [ + "../../../packages/sthrift/application-services/src", + "../../../packages/sthrift/data-sources-mongoose-models/src", + "../../../packages/sthrift/domain/src", + "../../../packages/sthrift/graphql/src", + "../../../packages/sthrift/persistence/src", + "../../../apps/ui-sharethrift/src", + "../../../packages/sthrift/ui-components/src" + ], + "exclude": [ + "cucumber.js", + "src/**", + "**/node_modules/**", + "**/arch-unit-tests/**", + "**/*.test.*", + "**/*.spec.*", + "**/*.stories.*", + "**/*.d.ts", + "**/dist/**/*.map", + "**/packages/sthrift-verification/**", + "**/packages/cellix/test-utils/**", + "**/packages/cellix/vitest-config/**", + "coverage/**", + "coverage-c8/**", + "coverage-vitest/**", + ".c8-output/**", + "**/generated.tsx" + ], + "excludeNodeModules": false +} diff --git a/packages/sthrift-verification/acceptance-tests/.gitignore b/packages/sthrift-verification/acceptance-tests/.gitignore index 78d6f5426..8072fd279 100644 --- a/packages/sthrift-verification/acceptance-tests/.gitignore +++ b/packages/sthrift-verification/acceptance-tests/.gitignore @@ -4,3 +4,6 @@ reports/ target/ *.log .portless/ +.c8-output/ +coverage/ +coverage-c8 diff --git a/packages/sthrift-verification/acceptance-tests/package.json b/packages/sthrift-verification/acceptance-tests/package.json index 9aa79e65c..7298746f6 100644 --- a/packages/sthrift-verification/acceptance-tests/package.json +++ b/packages/sthrift-verification/acceptance-tests/package.json @@ -8,9 +8,16 @@ "test:domain": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"domain\"}' --format json:./reports/cucumber-report-domain.json", "test:session:graphql": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"graphql\"}' --format json:./reports/cucumber-report-session-graphql.json", "test:session:mongodb": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"mongodb\"}' --format json:./reports/cucumber-report-session-mongodb.json", + "test:coverage:domain": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean -- cucumber-js --world-parameters '{\"tasks\":\"domain\"}' --format json:./reports/cucumber-report-domain.json", + "test:coverage:session:graphql": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"graphql\"}' --format json:./reports/cucumber-report-session-graphql.json", + "test:coverage:session:mongodb": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"mongodb\"}' --format json:./reports/cucumber-report-session-mongodb.json", + "test:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", + "test:coverage:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", + "test:coverage:acceptance": "pnpm run test:coverage:domain && pnpm run test:coverage:session:graphql && pnpm run test:coverage:session:mongodb && pnpm run test:coverage:ui && pnpm run test:coverage:report", + "test:coverage:report": "c8 report --allowExternal --temp-directory=.c8-output --reports-dir=coverage-c8 --reporter=lcov --exclude='**/node_modules/**' --exclude='**/*.d.ts' --exclude='**/*.stories.*' --exclude='**/generated.*'", "test:fast": "pnpm run test:domain && pnpm run test:session:graphql", - "test:all": "pnpm run test:domain && pnpm run test:session:graphql && pnpm run test:session:mongodb", - "clean": "rimraf dist reports target" + "test:all": "pnpm run test:domain && pnpm run test:session:graphql && pnpm run test:session:mongodb && pnpm run test:ui", + "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" }, "dependencies": { "@cucumber/cucumber": "^12.7.0", @@ -35,8 +42,19 @@ "@sthrift/domain": "workspace:*", "@sthrift/graphql": "workspace:*", "@sthrift/persistence": "workspace:*", + "@sthrift/ui-components": "workspace:*", + "@apps/ui-sharethrift": "workspace:*", "@types/graphql-depth-limit": "^1.1.6", "@types/node": "^24.6.1", + "c8": "^11.0.0", + "jsdom": "^26.1.0", + "@types/jsdom": "^21.1.7", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.12.0", + "antd": "^5.27.0", + "@ant-design/icons": "^6.1.0", + "dayjs": "^1.11.0", "graphql-depth-limit": "^1.1.0", "graphql-middleware": "^6.1.35", "mongodb": "^6.15.0", diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts index 8b1272d02..784027f0f 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts @@ -7,6 +7,7 @@ import type { ListingDetails } from '../tasks/domain/create-listing.ts'; import type { ListingNotes } from '../abilities/listing-types.ts'; import { CreateListing as SessionCreateListing } from '../tasks/session/create-listing.ts'; import { CreateListing as DomainCreateListing } from '../tasks/domain/create-listing.ts'; +import { CreateListing as UICreateListing } from '../tasks/ui/create-listing.ts'; import { ListingStatus } from '../questions/listing-status.ts'; import { ListingTitle } from '../questions/listing-title.ts'; @@ -17,6 +18,8 @@ function getCreateListingTask(level: string) { switch (level) { case 'session': return SessionCreateListing; + case 'ui': + return UICreateListing; default: return DomainCreateListing; } diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts new file mode 100644 index 000000000..972fd0dec --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts @@ -0,0 +1,103 @@ +import { Task, type Actor, notes } from '@serenity-js/core'; +import type { ListingDetails, ListingNotes } from '../../abilities/listing-types.ts'; +import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; + +export class CreateListing extends Task { + static with(details: ListingDetails) { + return new CreateListing(details); + } + + private constructor(private readonly details: ListingDetails) { + super(`renders create listing UI "${details.title}"`); + } + + async performAs(actor: Actor): Promise { + // 1. Perform domain validation first (same behavior as domain level) + const isDraft = !( + this.details.isDraft === 'false' || this.details.isDraft === false + ); + const state = isDraft ? 'draft' : 'active'; + + const ability = CreateListingAbility.as(actor); + ability.createDraftListing({ + title: this.details.title, + description: this.details.description, + category: this.details.category, + location: this.details.location, + state: isDraft ? 'Draft' : 'Active', + }); + + const listing = ability.getCreatedListing(); + if (!listing) { + throw new Error( + 'Domain CreateListingAbility.createDraftListing did not produce a listing', + ); + } + + // 2. Render UI components for code coverage + const { ensureJsdom, cleanupJsdom } = await import( + '../../../../shared/support/ui/jsdom-setup.ts' + ); + const { renderForCoverage } = await import( + '../../../../shared/support/ui/react-render.tsx' + ); + + ensureJsdom(); + + // Render the CreateListing presentational component + const { CreateListing: CreateListingComponent } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx' + ); + + const { cleanup } = await renderForCoverage( + CreateListingComponent as React.ComponentType>, + { + categories: [ + this.details.category ?? 'Other', + 'Electronics', + 'Sports', + ], + isLoading: false, + submissionStatus: 'idle' as const, + onSubmit: () => {}, + onCancel: () => {}, + uploadedImages: [], + onImageAdd: () => {}, + onImageRemove: () => {}, + onViewListing: () => {}, + onViewDraft: () => {}, + onModalClose: () => {}, + }, + { withRouter: true }, + ); + + cleanup(); + + // Also render the shared ListingForm from ui-components for coverage + const { ListingForm } = await import('@sthrift/ui-components'); + const { cleanup: cleanup2 } = await renderForCoverage( + ListingForm as React.ComponentType>, + { + categories: [this.details.category ?? 'Other', 'Electronics'], + isLoading: false, + maxCharacters: 2000, + handleFormSubmit: () => {}, + onCancel: () => {}, + }, + { withRouter: false }, + ); + + cleanup2(); + cleanupJsdom(); + + // 3. Store values in notes for assertion steps + await actor.attemptsTo( + notes().set('lastListingId', listing.id), + notes().set('lastListingTitle', listing.title), + notes().set('lastListingStatus', state), + ); + } + + override toString = () => + `renders create listing UI "${this.details.title}"`; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts index 4c4696c51..da749e49f 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts @@ -5,8 +5,10 @@ import type { ShareThriftWorld } from '../../../world.ts'; import { makeTestUserData, resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; import { CreateListing as SessionCreateListing } from '../../listing/tasks/session/create-listing.ts'; import { CreateListing as DomainCreateListing, type CreateListingInput } from '../../listing/tasks/domain/create-listing.ts'; +import { CreateListing as UICreateListing } from '../../listing/tasks/ui/create-listing.ts'; import { CreateReservationRequest as SessionCreateReservationRequest } from '../tasks/session/create-reservation-request.ts'; import { CreateReservationRequest as DomainCreateReservationRequest } from '../tasks/domain/create-reservation-request.ts'; +import { CreateReservationRequest as UICreateReservationRequest } from '../tasks/ui/create-reservation-request.ts'; import { GetReservationRequestCountForListing } from '../questions/get-reservation-request-count-for-listing.ts'; import { DomainGetReservationRequestCountForListing } from '../questions/domain-get-reservation-request-count-for-listing.ts'; import type { CreateReservationRequestInput, ReservationRequestNotes } from '../abilities/reservation-request-types.ts'; @@ -17,6 +19,8 @@ function getCreateListingTask(level: string) { switch (level) { case 'session': return SessionCreateListing; + case 'ui': + return UICreateListing; default: return DomainCreateListing; } @@ -26,6 +30,8 @@ function getCreateReservationRequestTask(level: string) { switch (level) { case 'session': return SessionCreateReservationRequest; + case 'ui': + return UICreateReservationRequest; default: return DomainCreateReservationRequest; } @@ -317,7 +323,7 @@ Then( async function (this: ShareThriftWorld) { const actor = actorCalled(lastActorName); const listingId = await getListingIdFromOwner('Bob'); - const countQuestion = this.level === 'domain' + const countQuestion = this.level === 'domain' || this.level === 'ui' ? DomainGetReservationRequestCountForListing.forListing(listingId) : GetReservationRequestCountForListing.forListing(listingId); diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts new file mode 100644 index 000000000..4dde4eec5 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts @@ -0,0 +1,131 @@ +import { Task, type Actor, notes } from '@serenity-js/core'; +import type { CreateReservationRequestInput, ReservationRequestNotes } from '../../abilities/reservation-request-types.ts'; +import { CreateReservationRequestAbility } from '../../abilities/create-reservation-request-ability.ts'; + +export class CreateReservationRequest extends Task { + static with(input: CreateReservationRequestInput) { + return new CreateReservationRequest(input); + } + + private constructor(private readonly input: CreateReservationRequestInput) { + super( + `renders reservation request UI for listing "${input.listingId}"`, + ); + } + + async performAs(actor: Actor): Promise { + // 1. Perform domain validation first (handles overlaps, missing fields, etc.) + const ability = CreateReservationRequestAbility.as(actor); + ability.createReservationRequest(this.input); + + const reservationRequest = ability.getCreatedAggregate(); + if (!reservationRequest) { + throw new Error( + 'Domain CreateReservationRequestAbility did not produce an aggregate', + ); + } + + // 2. Render UI components for code coverage + const { ensureJsdom, cleanupJsdom } = await import( + '../../../../shared/support/ui/jsdom-setup.ts' + ); + const { renderForCoverage } = await import( + '../../../../shared/support/ui/react-render.tsx' + ); + + ensureJsdom(); + + // Render the ReservationRequestForm component + const { ReservationRequestForm } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx' + ); + + const { cleanup } = await renderForCoverage( + ReservationRequestForm as React.ComponentType>, + { + userIsSharer: false, + isAuthenticated: true, + userReservationRequest: null, + onReserveClick: () => {}, + onCancelClick: () => {}, + reservationDates: { + startDate: this.input.reservationPeriodStart, + endDate: this.input.reservationPeriodEnd, + }, + onReservationDatesChange: () => {}, + reservationLoading: false, + otherReservationsLoading: false, + otherReservationsError: undefined, + otherReservations: [], + }, + { withRouter: true }, + ); + + cleanup(); + + // Also render the ReservationCard for broader coverage + try { + const { ReservationCard } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx' + ); + + const { cleanup: cleanup2 } = await renderForCoverage( + ReservationCard as React.ComponentType>, + { + reservation: { + id: this.input.listingId, + listing: { + title: 'Test Listing', + images: [], + }, + state: 'Requested', + reservationPeriodStart: + this.input.reservationPeriodStart.toISOString(), + reservationPeriodEnd: + this.input.reservationPeriodEnd.toISOString(), + }, + showActions: false, + }, + { withRouter: true }, + ); + + cleanup2(); + } catch { + // ReservationCard may have additional import requirements; skip gracefully + } + + cleanupJsdom(); + + // 3. Store values in notes for assertion steps + const startDate = + reservationRequest.reservationPeriodStart + .toISOString() + .split('T')[0] ?? ''; + const endDate = + reservationRequest.reservationPeriodEnd + .toISOString() + .split('T')[0] ?? ''; + + await actor.attemptsTo( + notes().set( + 'lastReservationRequestId', + reservationRequest.id, + ), + notes().set( + 'lastReservationRequestState', + reservationRequest.state, + ), + notes().set( + 'lastReservationRequestStartDate', + startDate, + ), + notes().set( + 'lastReservationRequestEndDate', + endDate, + ), + ); + } + + override toString = () => + `renders reservation request UI for listing "${this.input.listingId}"`; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts index e88f97676..66df650bc 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts @@ -30,7 +30,9 @@ export class ShareThriftCast implements Cast { } prepare(actor: Actor): Actor { - if (this.tasksLevel === 'domain') { + if (this.tasksLevel === 'domain' || this.tasksLevel === 'ui') { + // UI tests use domain abilities for setup steps (e.g., "has created a listing") + // and store results in notes; UI rendering happens in UI-specific tasks return actor.whoCan( TakeNotes.using(Notepad.empty()), ...listingAbilities.create(), diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs new file mode 100644 index 000000000..6f92980a9 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs @@ -0,0 +1,36 @@ +// ESM loader hooks for intercepting CSS, image, and other asset imports +// These run in Node.js's loader thread (plain JS required, no TypeScript) + +const ASSET_PATTERN = /\.(css|less|scss|sass|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|mp4|mp3)(\?.*)?$/; + +// Redirect antd/es/* ESM subpaths to antd/lib/* CJS subpaths to avoid +// ERR_REQUIRE_CYCLE_MODULE errors when Node.js processes ESM/CJS transitions +const ANTD_ES_PATTERN = /^antd\/es\//; + +export async function resolve(specifier, context, nextResolve) { + if (ASSET_PATTERN.test(specifier)) { + return { + url: new URL(specifier, context.parentURL).href, + shortCircuit: true, + }; + } + + // Redirect antd/es/* to antd/lib/* for Node.js CJS/ESM compatibility + if (ANTD_ES_PATTERN.test(specifier)) { + const cjsPath = specifier.replace('antd/es/', 'antd/lib/'); + return nextResolve(cjsPath, context); + } + + return nextResolve(specifier); +} + +export async function load(url, context, nextLoad) { + if (ASSET_PATTERN.test(url)) { + return { + format: 'module', + source: 'export default {};', + shortCircuit: true, + }; + } + return nextLoad(url, context); +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts new file mode 100644 index 000000000..aa6d4e8e1 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts @@ -0,0 +1,144 @@ +// Sets up a jsdom-based DOM environment for React component rendering in Node.js +// Must be called BEFORE any React component is imported +import { JSDOM } from 'jsdom'; + +let initialized = false; + +export function ensureJsdom(): void { + if (initialized) return; + + const dom = new JSDOM('
', { + url: 'http://localhost:3000', + pretendToBeVisual: true, + }); + + const { window } = dom; + + // Core DOM globals - use Object.defineProperty for read-only properties + globalThis.window = window as unknown as Window & typeof globalThis; + globalThis.document = window.document; + Object.defineProperty(globalThis, 'navigator', { + value: window.navigator, + writable: true, + configurable: true, + }); + + // DOM element constructors (needed by React + antd) + globalThis.HTMLElement = window.HTMLElement; + globalThis.HTMLInputElement = window.HTMLInputElement; + globalThis.HTMLTextAreaElement = window.HTMLTextAreaElement; + globalThis.HTMLSelectElement = window.HTMLSelectElement; + globalThis.HTMLButtonElement = window.HTMLButtonElement; + globalThis.HTMLFormElement = window.HTMLFormElement; + globalThis.HTMLDivElement = window.HTMLDivElement; + globalThis.HTMLSpanElement = window.HTMLSpanElement; + globalThis.HTMLImageElement = window.HTMLImageElement; + globalThis.HTMLAnchorElement = window.HTMLAnchorElement; + globalThis.Element = window.Element; + globalThis.Node = window.Node; + globalThis.Text = window.Text; + globalThis.DocumentFragment = window.DocumentFragment; + globalThis.SVGElement = window.SVGElement; + + // Event constructors + globalThis.Event = window.Event; + globalThis.CustomEvent = window.CustomEvent; + globalThis.MouseEvent = window.MouseEvent; + globalThis.KeyboardEvent = window.KeyboardEvent; + globalThis.FocusEvent = window.FocusEvent; + globalThis.InputEvent = window.InputEvent; + + // Other APIs needed by antd and React + globalThis.MutationObserver = window.MutationObserver; + globalThis.getComputedStyle = window.getComputedStyle; + globalThis.CSSStyleDeclaration = window.CSSStyleDeclaration; + globalThis.DOMParser = window.DOMParser; + globalThis.XMLSerializer = window.XMLSerializer; + globalThis.Range = window.Range; + globalThis.NodeList = window.NodeList; + globalThis.HTMLCollection = window.HTMLCollection; + + // Mock matchMedia (required by antd responsive components) + const matchMediaMock = () => ({ + matches: false, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + media: '', + onchange: null, + }); + window.matchMedia = window.matchMedia || matchMediaMock; + globalThis.matchMedia = window.matchMedia; + + // Mock ResizeObserver (required by antd) + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + + // Mock IntersectionObserver + globalThis.IntersectionObserver = class IntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + root = null; + rootMargin = ''; + thresholds = [] as number[]; + takeRecords() { return []; } + } as unknown as typeof IntersectionObserver; + + // Mock ShadowRoot (needed by antd icons / rc-util) + if (!globalThis.ShadowRoot) { + globalThis.ShadowRoot = class ShadowRoot {} as unknown as typeof ShadowRoot; + } + + // Mock scroll and selection APIs + window.scrollTo = () => {}; + globalThis.scrollTo = () => {}; + window.getSelection = () => null as unknown as Selection; + + // Mock requestAnimationFrame (not always present in jsdom) + if (!globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 0) as unknown as number; + }; + globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id); + } + + // Mock import.meta.env for Vite-specific code paths + if (!import.meta.env) { + (import.meta as { env: Record }).env = {}; + } + + // Suppress React error boundary warnings in console during acceptance tests + // These are expected since antd components may partially fail to render in jsdom + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { + const message = String(args[0] ?? ''); + if ( + message.includes('Consider adding an error boundary') || + message.includes('An error occurred in the <') || + message.includes('Error: Uncaught') || + message.includes('Warning: Can not find FormContext') + ) { + return; // Suppress expected rendering warnings + } + originalConsoleError.apply(console, args); + }; + + initialized = true; +} + +export function cleanupJsdom(): void { + if (!initialized) return; + // Clean up body for next test + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx new file mode 100644 index 000000000..bcd8b24d2 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx @@ -0,0 +1,67 @@ +// React render utility for acceptance tests +// Renders components within required providers (Router, etc.) for coverage testing +import { createElement, type ComponentType, type ReactElement } from 'react'; +import * as React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; + +// Make React available globally for components using the classic JSX transform +// or loaded through CJS paths where automatic JSX transform doesn't inject imports +if (!globalThis.React) { + globalThis.React = React; +} + +interface RenderResult { + html: string; + cleanup: () => void; +} + +/** + * Render a React component in the jsdom environment for code coverage. + * Wraps the component with MemoryRouter for components using react-router hooks. + */ +export async function renderForCoverage

>( + Component: ComponentType

, + props: P, + options?: { withRouter?: boolean; routerPath?: string }, +): Promise { + if (typeof document === 'undefined') { + throw new Error('DOM environment not set up. Call ensureJsdom() first.'); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + + let root: Root | undefined; + + const withRouter = options?.withRouter ?? true; + + let element: ReactElement; + const componentElement = createElement(Component, props); + + if (withRouter) { + element = createElement( + MemoryRouter, + { initialEntries: [options?.routerPath ?? '/'] }, + componentElement, + ); + } else { + element = componentElement; + } + + root = createRoot(container); + root.render(element); + + // Allow React to flush the render + await new Promise((resolve) => setTimeout(resolve, 50)); + + const html = container.innerHTML; + + return { + html, + cleanup: () => { + root?.unmount(); + container.remove(); + }, + }; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts new file mode 100644 index 000000000..b376c667a --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts @@ -0,0 +1,5 @@ +// Register ESM loader hooks for CSS/image/asset mocking +// This file is imported via NODE_OPTIONS --import flag for UI tests +import { register } from 'node:module'; + +register('./asset-loader-hooks.mjs', import.meta.url); diff --git a/packages/sthrift-verification/acceptance-tests/src/world.ts b/packages/sthrift-verification/acceptance-tests/src/world.ts index 2e4b5c29a..7fd40c1c4 100644 --- a/packages/sthrift-verification/acceptance-tests/src/world.ts +++ b/packages/sthrift-verification/acceptance-tests/src/world.ts @@ -6,7 +6,7 @@ import { clearMockListings } from './shared/support/test-data/listing.test-data. import { clearMockReservationRequests } from './shared/support/test-data/reservation-request.test-data.ts'; import * as infra from './shared/support/shared-infrastructure.ts'; -export type TaskLevel = 'domain' | 'session'; +export type TaskLevel = 'domain' | 'session' | 'ui'; export type SessionType = 'graphql' | 'mongodb'; export interface WorldParameters { @@ -35,6 +35,11 @@ export class ShareThriftWorld extends World { await infra.ensureSessionServers(this.sessionType); } + if (this.tasksLevel === 'ui') { + // jsdom setup is done lazily in UI tasks via dynamic imports + // No server infrastructure needed for UI tests + } + const { apiUrl } = infra.getState(); if (apiUrl) { diff --git a/packages/sthrift-verification/acceptance-tests/turbo.json b/packages/sthrift-verification/acceptance-tests/turbo.json index 898188ce4..90f3395d3 100644 --- a/packages/sthrift-verification/acceptance-tests/turbo.json +++ b/packages/sthrift-verification/acceptance-tests/turbo.json @@ -25,6 +25,11 @@ "dependsOn": ["^build"], "inputs": ["src/**", "cucumber.js"], "outputs": ["reports/**"] + }, + "test:ui": { + "dependsOn": ["^build"], + "inputs": ["src/**", "cucumber.js"], + "outputs": ["reports/**"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index becf6aa91..426ca2b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1161,9 +1161,15 @@ importers: specifier: ^4.0.0 version: 4.0.0 devDependencies: + '@ant-design/icons': + specifier: ^6.1.0 + version: 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@apollo/server': specifier: '>=5.5.0' version: 5.5.0(graphql@16.13.1) + '@apps/ui-sharethrift': + specifier: workspace:* + version: link:../../../apps/ui-sharethrift '@cellix/service-messaging-base': specifier: workspace:* version: link:../../cellix/service-messaging-base @@ -1197,24 +1203,51 @@ importers: '@sthrift/persistence': specifier: workspace:* version: link:../../sthrift/persistence + '@sthrift/ui-components': + specifier: workspace:* + version: link:../../sthrift/ui-components '@types/graphql-depth-limit': specifier: ^1.1.6 version: 1.1.6 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 '@types/node': specifier: ^24.10.7 version: 24.12.0 + antd: + specifier: ^5.27.0 + version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + c8: + specifier: ^11.0.0 + version: 11.0.0 + dayjs: + specifier: ^1.11.0 + version: 1.11.20 graphql-depth-limit: specifier: ^1.1.0 version: 1.1.0(graphql@16.13.1) graphql-middleware: specifier: ^6.1.35 version: 6.1.35(graphql@16.13.1) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 mongodb: specifier: ^6.15.0 version: 6.21.0 mongodb-memory-server: specifier: ^10.2.0 version: 10.4.3 + react: + specifier: ^19.1.0 + version: 19.2.4 + react-dom: + specifier: ^19.1.0 + version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.12.0 + version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) rimraf: specifier: ^6.0.1 version: 6.1.3 @@ -3919,6 +3952,10 @@ packages: '@types/node': optional: true + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5294,6 +5331,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -5392,6 +5432,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -6186,6 +6229,16 @@ packages: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} + c8@11.0.0: + resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} + engines: {node: 20 || >=22} + hasBin: true + peerDependencies: + monocart-coverage-reports: ^2 + peerDependenciesMeta: + monocart-coverage-reports: + optional: true + cache-base@1.0.1: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} @@ -7519,6 +7572,10 @@ packages: resolution: {integrity: sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==} engines: {node: '>=0.10.0'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@2.1.4: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} @@ -11152,6 +11209,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -11560,6 +11621,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@8.0.0: + resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} + engines: {node: 20 || >=22} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -12059,6 +12124,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -14196,7 +14265,7 @@ snapshots: '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.0.1) '@cucumber/junit-xml-formatter': 0.9.0(@cucumber/messages@32.0.1) - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) + '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.2.0) '@cucumber/messages': 32.0.1 '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.7.0)(@cucumber/messages@32.0.1) '@cucumber/tag-expressions': 9.1.0 @@ -14233,7 +14302,7 @@ snapshots: '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1))(@cucumber/messages@32.0.1)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) + '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.2.0) '@cucumber/messages': 32.0.1 commander: 14.0.0 source-map-support: 0.5.21 @@ -14282,9 +14351,9 @@ snapshots: luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1)': + '@cucumber/message-streams@4.0.1(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 '@cucumber/messages@26.0.1': dependencies: @@ -15925,6 +15994,8 @@ snapshots: optionalDependencies: '@types/node': 24.12.0 + '@istanbuljs/schema@0.1.3': {} + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 @@ -17483,6 +17554,12 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10': @@ -17594,6 +17671,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} '@types/unist@2.0.11': {} @@ -18635,6 +18714,20 @@ snapshots: bytestreamjs@2.0.1: {} + c8@11.0.0: + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@istanbuljs/schema': 0.1.3 + find-up: 5.0.0 + foreground-child: 3.3.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + test-exclude: 8.0.0 + v8-to-istanbul: 9.3.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + cache-base@1.0.1: dependencies: collection-visit: 1.0.0 @@ -20182,6 +20275,11 @@ snapshots: dependencies: for-in: 1.0.2 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@2.1.4: {} form-data@2.5.5: @@ -24580,6 +24678,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 @@ -25070,6 +25170,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@8.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 13.0.6 + minimatch: 10.2.4 + text-decoder@1.2.7: dependencies: b4a: 1.8.0 @@ -25552,6 +25658,12 @@ snapshots: uuid@9.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 From c536d51c24acfa2a1ef2c760ac3cd275afe100e2 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 2 Apr 2026 16:38:46 -0400 Subject: [PATCH 2/7] progress checkin for ui/page adapter/temporary name for reusable package for these tests. clean up still todo --- .../acceptance-tests/package.json | 33 +-- .../listing/abilities/api-listing-session.ts | 129 -------- .../abilities/create-listing-ability.ts | 2 +- .../abilities/graphql-listing-session.ts | 9 - .../abilities/mongo-listing-session.ts | 9 - .../listing/questions/listing-status.ts | 57 +++- .../listing/questions/listing-title.ts | 57 +++- .../step-definitions/create-listing.steps.ts | 147 ++++++--- .../listing/tasks/api/create-listing.ts | 154 ++++++++++ .../listing/tasks/domain/create-listing.ts | 65 ---- .../listing/tasks/session/create-listing.ts | 86 ------ .../listing/tasks/ui/create-listing.ts | 164 ++++++---- .../api-reservation-request-session.ts | 122 -------- .../create-reservation-request-ability.ts | 2 +- .../graphql-reservation-request-session.ts | 3 - .../mongo-reservation-request-session.ts | 3 - ...t-reservation-request-count-for-listing.ts | 25 +- .../create-reservation-request.steps.ts | 233 ++++++++++----- .../tasks/api/create-reservation-request.ts | 155 ++++++++++ .../domain/create-reservation-request.ts | 59 ---- .../session/create-reservation-request.ts | 65 ---- .../tasks/ui/create-reservation-request.ts | 200 ++++++++----- .../src/shared/abilities/api-session.ts | 79 ----- .../src/shared/abilities/graphql-client.ts | 51 ++++ .../shared/abilities/multi-context-session.ts | 31 -- .../src/shared/abilities/session.ts | 45 --- .../support/application-services/index.ts | 1 - .../real-application-services.ts | 2 +- .../account-plan.test-app-services.ts | 25 -- .../appeal-request.test-app-services.ts | 45 --- .../conversation.test-app-services.ts | 26 -- .../listing.test-app-services.ts | 100 ------- .../reservation-request.test-app-services.ts | 67 ----- .../user.test-app-services.ts | 121 -------- .../test-application-services.ts | 41 --- .../src/shared/support/cast.ts | 32 +- .../src/shared/support/hooks.ts | 16 +- .../support/servers/test-mongodb-server.ts | 3 +- .../shared/support/shared-infrastructure.ts | 24 +- .../shared/support/ui/asset-loader-hooks.mjs | 27 +- .../src/shared/support/ui/jsdom-setup.ts | 25 +- .../acceptance-tests/src/world.ts | 28 +- .../e2e-tests/package.json | 1 + .../support/servers/test-mongodb-server.ts | 3 +- .../shared/support/shared-infrastructure.ts | 2 +- .../test-data/account-plan.test-data.ts | 65 ---- .../test-data/appeal-request.test-data.ts | 37 --- .../test-data/conversation.test-data.ts | 32 -- .../support/test-data/listing.test-data.ts | 70 ----- .../reservation-request.test-data.ts | 71 ----- .../shared/support/test-data/test-actors.ts | 23 -- .../support/test-data/user.test-data.ts | 279 ------------------ .../src/shared/support/test-data/utils.ts | 7 - .../e2e-tests/src/world.ts | 3 +- .../sthrift-verification/shared/package.json | 32 ++ .../src/pages/adapters/jsdom-adapter.ts | 127 ++++++++ .../src/pages/adapters/playwright-adapter.ts | 77 +++++ .../shared/src/pages/index.ts | 7 + .../shared/src/pages/listing.page.ts | 111 +++++++ .../shared/src/pages/page-adapter.ts | 40 +++ .../shared/src/pages/reservation.page.ts | 53 ++++ .../src}/test-data/account-plan.test-data.ts | 0 .../test-data/appeal-request.test-data.ts | 0 .../src}/test-data/conversation.test-data.ts | 0 .../shared/src/test-data/index.ts | 25 ++ .../src}/test-data/listing.test-data.ts | 0 .../reservation-request.test-data.ts | 0 .../src}/test-data/test-actors.ts | 0 .../src}/test-data/user.test-data.ts | 0 .../support => shared/src}/test-data/utils.ts | 0 .../sthrift-verification/shared/tsconfig.json | 10 + pnpm-lock.yaml | 45 ++- 72 files changed, 1565 insertions(+), 2123 deletions(-) delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/api-listing-session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/graphql-listing-session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/mongo-listing-session.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/domain/create-listing.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/session/create-listing.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/api-reservation-request-session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/graphql-reservation-request-session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/mongo-reservation-request-session.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/domain/create-reservation-request.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/session/create-reservation-request.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/abilities/api-session.ts create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/abilities/multi-context-session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/abilities/session.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/account-plan.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/appeal-request.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/conversation.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/listing.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/reservation-request.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/user.test-app-services.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-application-services.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/account-plan.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/appeal-request.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/conversation.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/listing.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/reservation-request.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/test-actors.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/user.test-data.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/support/test-data/utils.ts create mode 100644 packages/sthrift-verification/shared/package.json create mode 100644 packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts create mode 100644 packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts create mode 100644 packages/sthrift-verification/shared/src/pages/index.ts create mode 100644 packages/sthrift-verification/shared/src/pages/listing.page.ts create mode 100644 packages/sthrift-verification/shared/src/pages/page-adapter.ts create mode 100644 packages/sthrift-verification/shared/src/pages/reservation.page.ts rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/account-plan.test-data.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/appeal-request.test-data.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/conversation.test-data.ts (100%) create mode 100644 packages/sthrift-verification/shared/src/test-data/index.ts rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/listing.test-data.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/reservation-request.test-data.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/test-actors.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/user.test-data.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/shared/support => shared/src}/test-data/utils.ts (100%) create mode 100644 packages/sthrift-verification/shared/tsconfig.json diff --git a/packages/sthrift-verification/acceptance-tests/package.json b/packages/sthrift-verification/acceptance-tests/package.json index 7298746f6..853e5512d 100644 --- a/packages/sthrift-verification/acceptance-tests/package.json +++ b/packages/sthrift-verification/acceptance-tests/package.json @@ -1,22 +1,17 @@ { "name": "@sthrift-verification/acceptance-tests", "version": "1.0.0", - "description": "Cucumber Screenplay acceptance tests for ShareThrift domain (domain + session levels)", + "description": "Cucumber Screenplay acceptance tests for ShareThrift (api + ui levels)", "private": true, "type": "module", "scripts": { - "test:domain": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"domain\"}' --format json:./reports/cucumber-report-domain.json", - "test:session:graphql": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"graphql\"}' --format json:./reports/cucumber-report-session-graphql.json", - "test:session:mongodb": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"mongodb\"}' --format json:./reports/cucumber-report-session-mongodb.json", - "test:coverage:domain": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean -- cucumber-js --world-parameters '{\"tasks\":\"domain\"}' --format json:./reports/cucumber-report-domain.json", - "test:coverage:session:graphql": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"graphql\"}' --format json:./reports/cucumber-report-session-graphql.json", - "test:coverage:session:mongodb": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"session\",\"session\":\"mongodb\"}' --format json:./reports/cucumber-report-session-mongodb.json", + "test:api": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"api\"}' --format json:./reports/cucumber-report-api.json", "test:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", + "test:coverage:api": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean -- cucumber-js --world-parameters '{\"tasks\":\"api\"}' --format json:./reports/cucumber-report-api.json", "test:coverage:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", - "test:coverage:acceptance": "pnpm run test:coverage:domain && pnpm run test:coverage:session:graphql && pnpm run test:coverage:session:mongodb && pnpm run test:coverage:ui && pnpm run test:coverage:report", + "test:coverage:acceptance": "pnpm run test:coverage:api && pnpm run test:coverage:ui && pnpm run test:coverage:report", "test:coverage:report": "c8 report --allowExternal --temp-directory=.c8-output --reports-dir=coverage-c8 --reporter=lcov --exclude='**/node_modules/**' --exclude='**/*.d.ts' --exclude='**/*.stories.*' --exclude='**/generated.*'", - "test:fast": "pnpm run test:domain && pnpm run test:session:graphql", - "test:all": "pnpm run test:domain && pnpm run test:session:graphql && pnpm run test:session:mongodb && pnpm run test:ui", + "test:all": "pnpm run test:api && pnpm run test:ui", "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" }, "dependencies": { @@ -30,7 +25,9 @@ "std-env": "^4.0.0" }, "devDependencies": { + "@ant-design/icons": "^6.1.0", "@apollo/server": "^5.5.0", + "@apps/ui-sharethrift": "workspace:*", "@cellix/service-messaging-base": "workspace:*", "@cellix/service-mongoose": "workspace:*", "@cellix/service-payment-base": "workspace:*", @@ -43,22 +40,22 @@ "@sthrift/graphql": "workspace:*", "@sthrift/persistence": "workspace:*", "@sthrift/ui-components": "workspace:*", - "@apps/ui-sharethrift": "workspace:*", + "@sthrift-verification/shared": "workspace:*", + "@testing-library/react": "^16.3.2", "@types/graphql-depth-limit": "^1.1.6", - "@types/node": "^24.6.1", - "c8": "^11.0.0", - "jsdom": "^26.1.0", "@types/jsdom": "^21.1.7", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router-dom": "^7.12.0", + "@types/node": "^24.6.1", "antd": "^5.27.0", - "@ant-design/icons": "^6.1.0", + "c8": "^11.0.0", "dayjs": "^1.11.0", "graphql-depth-limit": "^1.1.0", "graphql-middleware": "^6.1.35", + "jsdom": "^26.1.0", "mongodb": "^6.15.0", "mongodb-memory-server": "^10.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.12.0", "rimraf": "^6.0.1", "tsx": "^4.20.3", "typescript": "^5.4.5" diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/api-listing-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/api-listing-session.ts deleted file mode 100644 index b999dffa9..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/api-listing-session.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ApiSession } from '../../../shared/abilities/api-session.ts'; -import type { CreateItemListingInput, ItemListingResponse } from './listing-types.ts'; - - -interface ListingSessionConfig { - // Include isDraft in serialization (MongoDB only) - includeIsDraft?: boolean; -} - -export abstract class ApiListingSession extends ApiSession { - context = 'listing'; - protected config: ListingSessionConfig = {}; - - constructor(apiUrl: string, authToken?: string) { - super(apiUrl, authToken); - this.registerOperations(); - } - - protected registerOperations(): void { - this.registerOperation('listing:create', (input) => - this.handleCreateListing(input as CreateItemListingInput), - ); - this.registerOperation('listing:getById', (input) => - this.handleGetListingById(input as { id: string }), - ); - } - - createItemListing(input: CreateItemListingInput): Promise { - return this.execute('listing:create', input); - } - - getListingById(id: string): Promise { - return this.execute<{ id: string }, ItemListingResponse | null>('listing:getById', { id }); - } - - protected async handleCreateListing(input: CreateItemListingInput): Promise { - const mutation = ` - mutation CreateItemListing($input: ItemListingCreateInput!) { - createItemListing(input: $input) { - status { - success - errorMessage - } - listing { - id - title - description - category - location - state - sharingPeriodStart - sharingPeriodEnd - images - } - } - } - `; - - const response = await this.executeGraphQL(mutation, { - input: this.serializeInput(input), - }); - const mutationResult = response.data['createItemListing'] as Record; - const status = mutationResult['status'] as Record | undefined; - - if (status && !status['success']) { - throw new Error(String(status['errorMessage'] ?? 'Failed to create listing')); - } - - const createItemListingData = (mutationResult['listing'] ?? {}) as Record; - return this.deserializeItemListing(createItemListingData); - } - - protected async handleGetListingById(input: { id: string }): Promise { - const query = ` - query GetListing($id: ObjectID!) { - itemListing(id: $id) { - id - title - description - category - location - state - sharingPeriodStart - sharingPeriodEnd - images - } - } - `; - - const response = await this.executeGraphQL(query, { id: input.id }); - const itemListingData = response.data['itemListing'] as Record | undefined; - return itemListingData ? this.deserializeItemListing(itemListingData) : null; - } - - protected serializeInput(input: CreateItemListingInput): Record { - const serialized: Record = { - ...input, - sharingPeriodStart: input.sharingPeriodStart.toISOString(), - sharingPeriodEnd: input.sharingPeriodEnd.toISOString(), - }; - - // MongoDB requires explicit isDraft parameter, GraphQL doesn't - if (this.config.includeIsDraft) { - serialized['isDraft'] = input.isDraft ?? true; - } - - return serialized; - } - - private toDate(value: unknown): Date { - if (!value) return new Date(); - const dateStr = typeof value === 'string' ? value : String(value); - return new Date(dateStr); - } - - protected deserializeItemListing(data: Record): ItemListingResponse { - return { - id: String(data['id']), - title: String(data['title']), - description: String(data['description']), - category: String(data['category']), - location: String(data['location']), - state: String(data['state']) as 'draft' | 'published', - sharingPeriodStart: this.toDate(data['sharingPeriodStart']), - sharingPeriodEnd: this.toDate(data['sharingPeriodEnd']), - images: Array.isArray(data['images']) ? data['images'] : [], - }; - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts index 9c681d640..57d84b19b 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts @@ -1,7 +1,7 @@ import { Ability } from '@serenity-js/core'; import { Domain } from '@sthrift/domain'; import { makeItemListingProps, makeSharerUser, ONE_DAY_MS, DEFAULT_SHARING_PERIOD_DAYS } from '../../../shared/support/domain-test-helpers.ts'; -import { listings } from '../../../shared/support/test-data/listing.test-data.ts'; +import { listings } from '@sthrift-verification/shared/test-data'; type Passport = Domain.Passport; type ItemListingProps = Domain.Contexts.Listing.ItemListing.ItemListingProps; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/graphql-listing-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/graphql-listing-session.ts deleted file mode 100644 index bf57131f1..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/graphql-listing-session.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiListingSession } from './api-listing-session.ts'; - -export class GraphQLListingSession extends ApiListingSession { - constructor(apiUrl: string, authToken?: string) { - super(apiUrl, authToken); - // GraphQL doesn't need isDraft parameter - this.config.includeIsDraft = false; - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/mongo-listing-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/mongo-listing-session.ts deleted file mode 100644 index b411c565b..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/mongo-listing-session.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiListingSession } from './api-listing-session.ts'; - -export class MongoListingSession extends ApiListingSession { - constructor(apiUrl: string, authToken?: string) { - super(apiUrl, authToken); - // MongoDB requires explicit isDraft parameter - this.config.includeIsDraft = true; - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts index e3e5f3cd4..23421256e 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts @@ -1,13 +1,27 @@ -import { Question, type Actor, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; -import { getSession } from '../../../shared/abilities/session.ts'; +import { + type Actor, + type AnswersQuestions, + notes, + Question, + type UsesAbilities, +} from '@serenity-js/core'; +import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; +const GET_LISTING_QUERY = ` + query GetListing($id: ObjectID!) { + itemListing(id: $id) { id state } + } +`; + export class ListingStatus extends Question> { constructor() { super('listing status'); } - override answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + override answeredBy( + actor: AnswersQuestions & UsesAbilities, + ): Promise { return this.resolveStatus(actor); } @@ -19,12 +33,14 @@ export class ListingStatus extends Question> { return 'the listing status'; } - private async resolveStatus(actor: AnswersQuestions & UsesAbilities): Promise { + private async resolveStatus( + actor: AnswersQuestions & UsesAbilities, + ): Promise { const listingId = await this.readNote(actor, 'lastListingId'); - const sessionStatus = await this.readStatusFromSession(actor, listingId); - if (sessionStatus) { - return this.normalizeStatus(sessionStatus); + const apiStatus = await this.readStatusFromApi(actor, listingId); + if (apiStatus) { + return this.normalizeStatus(apiStatus); } const domainStatus = this.readStatusFromDomain(actor); @@ -42,29 +58,44 @@ export class ListingStatus extends Question> { return this.normalizeStatus(notedStatus); } - private async readStatusFromSession(actor: AnswersQuestions & UsesAbilities, listingId?: string): Promise { + private async readStatusFromApi( + actor: AnswersQuestions & UsesAbilities, + listingId?: string, + ): Promise { if (!listingId) { return undefined; } try { - const session = getSession(actor as unknown as Actor, 'listing'); - const listing = await session.execute<{ id: string }, { state?: string } | null>('listing:getById', { id: listingId }); + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(GET_LISTING_QUERY, { + id: listingId, + }); + const listing = response.data.itemListing as + | Record + | undefined; return listing?.state ? String(listing.state) : undefined; } catch { return undefined; } } - private readStatusFromDomain(actor: AnswersQuestions & UsesAbilities): string | undefined { + private readStatusFromDomain( + actor: AnswersQuestions & UsesAbilities, + ): string | undefined { try { - return CreateListingAbility.as(actor as unknown as Actor).getCreatedListing()?.state; + return CreateListingAbility.as( + actor as unknown as Actor, + ).getCreatedListing()?.state; } catch { return undefined; } } - private async readNote(actor: AnswersQuestions & UsesAbilities, key: 'lastListingId' | 'lastListingTitle' | 'lastListingStatus'): Promise { + private async readNote( + actor: AnswersQuestions & UsesAbilities, + key: 'lastListingId' | 'lastListingTitle' | 'lastListingStatus', + ): Promise { try { return await actor.answer(notes>().get(key)); } catch { diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts index 716809092..5ae099f98 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts @@ -1,7 +1,19 @@ -import { Question, type Actor, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; -import { getSession } from '../../../shared/abilities/session.ts'; +import { + type Actor, + type AnswersQuestions, + notes, + Question, + type UsesAbilities, +} from '@serenity-js/core'; +import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; +const GET_LISTING_QUERY = ` + query GetListing($id: ObjectID!) { + itemListing(id: $id) { id title } + } +`; + export class ListingTitle extends Question> { constructor() { super('listing title'); @@ -11,19 +23,23 @@ export class ListingTitle extends Question> { return new ListingTitle(); } - override answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + override answeredBy( + actor: AnswersQuestions & UsesAbilities, + ): Promise { return this.resolveTitle(actor); } override toString = () => 'listing title'; - private async resolveTitle(actor: AnswersQuestions & UsesAbilities): Promise { + private async resolveTitle( + actor: AnswersQuestions & UsesAbilities, + ): Promise { const notedTitle = await this.readNote(actor, 'lastListingTitle'); const listingId = await this.readNote(actor, 'lastListingId'); - const sessionTitle = await this.readTitleFromSession(actor, listingId); - if (sessionTitle) { - return sessionTitle; + const apiTitle = await this.readTitleFromApi(actor, listingId); + if (apiTitle) { + return apiTitle; } const domainTitle = this.readTitleFromDomain(actor); @@ -40,29 +56,44 @@ export class ListingTitle extends Question> { return notedTitle; } - private async readTitleFromSession(actor: AnswersQuestions & UsesAbilities, listingId?: string): Promise { + private async readTitleFromApi( + actor: AnswersQuestions & UsesAbilities, + listingId?: string, + ): Promise { if (!listingId) { return undefined; } try { - const session = getSession(actor as unknown as Actor, 'listing'); - const listing = await session.execute<{ id: string }, { title?: string } | null>('listing:getById', { id: listingId }); + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(GET_LISTING_QUERY, { + id: listingId, + }); + const listing = response.data.itemListing as + | Record + | undefined; return listing?.title ? String(listing.title) : undefined; } catch { return undefined; } } - private readTitleFromDomain(actor: AnswersQuestions & UsesAbilities): string | undefined { + private readTitleFromDomain( + actor: AnswersQuestions & UsesAbilities, + ): string | undefined { try { - return CreateListingAbility.as(actor as unknown as Actor).getCreatedListing()?.title; + return CreateListingAbility.as( + actor as unknown as Actor, + ).getCreatedListing()?.title; } catch { return undefined; } } - private async readNote(actor: AnswersQuestions & UsesAbilities, key: 'lastListingId' | 'lastListingTitle'): Promise { + private async readNote( + actor: AnswersQuestions & UsesAbilities, + key: 'lastListingId' | 'lastListingTitle', + ): Promise { try { return await actor.answer(notes>().get(key)); } catch { diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts index 784027f0f..410afab6f 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts @@ -1,28 +1,23 @@ -import { Given, When, Then, type DataTable } from '@cucumber/cucumber'; -import { actorCalled, notes } from '@serenity-js/core'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { Ensure, equals } from '@serenity-js/assertions'; -import type { ShareThriftWorld } from '../../../world.ts'; +import { actorCalled, notes } from '@serenity-js/core'; import { resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; -import type { ListingDetails } from '../tasks/domain/create-listing.ts'; -import type { ListingNotes } from '../abilities/listing-types.ts'; -import { CreateListing as SessionCreateListing } from '../tasks/session/create-listing.ts'; -import { CreateListing as DomainCreateListing } from '../tasks/domain/create-listing.ts'; -import { CreateListing as UICreateListing } from '../tasks/ui/create-listing.ts'; +import type { ShareThriftWorld } from '../../../world.ts'; +import type { + ListingDetails, + ListingNotes, +} from '../abilities/listing-types.ts'; import { ListingStatus } from '../questions/listing-status.ts'; import { ListingTitle } from '../questions/listing-title.ts'; +import { CreateListing as ApiCreateListing } from '../tasks/api/create-listing.ts'; +import { CreateListing as UICreateListing } from '../tasks/ui/create-listing.ts'; // Track last actor used in When steps so Then steps can reference them without hardcoding let lastActorName = 'Alice'; function getCreateListingTask(level: string) { - switch (level) { - case 'session': - return SessionCreateListing; - case 'ui': - return UICreateListing; - default: - return DomainCreateListing; - } + if (level === 'api') return ApiCreateListing; + return UICreateListing; } Given( @@ -48,26 +43,35 @@ Given( location: 'Test Location', }), ); - - }, + }, ); When( '{word} creates a listing with:', - async function (this: ShareThriftWorld, actorName: string, dataTable: DataTable) { + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { lastActorName = actorName; const actor = actorCalled(actorName); const details = dataTable.rowsHash(); const CreateListing = getCreateListingTask(this.level); - await actor.attemptsTo(CreateListing.with(details as unknown as ListingDetails)); + await actor.attemptsTo( + CreateListing.with(details as unknown as ListingDetails), + ); }, ); When( '{word} attempts to create a listing with:', - async function (this: ShareThriftWorld, actorName: string, dataTable: DataTable) { + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { lastActorName = actorName; const actor = actorCalled(actorName); const details = dataTable.rowsHash(); @@ -76,16 +80,28 @@ When( // Clear notes from any previous scenario to prevent state leakage await actor.attemptsTo( - notes().set('lastListingId', undefined as unknown as string), - notes().set('lastValidationError', undefined as unknown as string), + notes().set( + 'lastListingId', + undefined as unknown as string, + ), + notes().set( + 'lastValidationError', + undefined as unknown as string, + ), ); try { - await actor.attemptsTo(CreateListing.with(details as unknown as ListingDetails)); + await actor.attemptsTo( + CreateListing.with(details as unknown as ListingDetails), + ); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); await actor.attemptsTo( - notes<{lastValidationError: string}>().set('lastValidationError', errorMessage), + notes<{ lastValidationError: string }>().set( + 'lastValidationError', + errorMessage, + ), ); } }, @@ -93,7 +109,11 @@ When( Then( '{word} sees the listing in {word} status', - async function (this: ShareThriftWorld, actorName: string, expectedStatus: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + expectedStatus: string, + ) { const actor = actorCalled(actorName); await actor.attemptsTo( @@ -104,7 +124,11 @@ Then( Then( '{word} sees the listing title as {string}', - async function (this: ShareThriftWorld, actorName: string, expectedTitle: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + expectedTitle: string, + ) { const actor = actorCalled(actorName); await actor.attemptsTo( @@ -119,15 +143,17 @@ Then( const actor = actorCalled(lastActorName); // Verify listing was created and is in the expected state - const listingId = await actor.answer(notes().get('lastListingId')); + const listingId = await actor.answer( + notes().get('lastListingId'), + ); if (!listingId) { - throw new Error('Expected a listing to exist before checking its daily rate'); + throw new Error( + 'Expected a listing to exist before checking its daily rate', + ); } // Verify listing is in draft status (confirms the full creation path worked) - await actor.attemptsTo( - Ensure.that(ListingStatus.of(), equals('draft')), - ); + await actor.attemptsTo(Ensure.that(ListingStatus.of(), equals('draft'))); // TODO: Verify actual daily rate value once domain model exposes it via notes. if (!expectedRate) { @@ -138,14 +164,20 @@ Then( Then( '{word} should see a listing error for {string}', - async function (this: ShareThriftWorld, actorName: string, fieldName: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + fieldName: string, + ) { const resolvedActorName = resolveActorName(actorName); const actor = actorCalled(resolvedActorName); // Check stored validation error from task execution (domain/session levels) let storedError: string | undefined; try { - storedError = await actor.answer(notes<{lastValidationError?: string}>().get('lastValidationError')); + storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); } catch { // No error in notes } @@ -154,7 +186,10 @@ Then( const lowerError = storedError.toLowerCase(); const lowerField = fieldName.toLowerCase(); const isFieldMentioned = lowerError.includes(lowerField); - const isValidationPattern = /wrong raw value type|cannot be empty|required|missing|invalid/i.test(storedError); + const isValidationPattern = + /wrong raw value type|cannot be empty|required|missing|invalid/i.test( + storedError, + ); if (!isFieldMentioned && !isValidationPattern) { throw new Error( @@ -164,47 +199,59 @@ Then( let listingId: string | undefined; try { - listingId = await actor.answer(notes().get('lastListingId')); + listingId = await actor.answer( + notes().get('lastListingId'), + ); } catch { // expected } if (listingId) { throw new Error( `Expected listing creation to be blocked by "${fieldName}" validation, ` + - `but a listing was created with id: ${listingId}`, + `but a listing was created with id: ${listingId}`, ); } return; } - throw new Error(`Expected a validation error for "${fieldName}" but none was found`); + throw new Error( + `Expected a validation error for "${fieldName}" but none was found`, + ); }, ); Then( '{word} should see a listing error {string}', - async function (this: ShareThriftWorld, actorName: string, expectedMessage: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + expectedMessage: string, + ) { const resolvedActorName = resolveActorName(actorName); const actor = actorCalled(resolvedActorName); let storedError: string | undefined; try { - storedError = await actor.answer(notes<{lastValidationError?: string}>().get('lastValidationError')); + storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); } catch { // No error stored } if (storedError) { if (!storedError.includes(expectedMessage)) { - throw new Error(`Expected error message "${expectedMessage}", but got: "${storedError}"`); + throw new Error( + `Expected error message "${expectedMessage}", but got: "${storedError}"`, + ); } return; } throw new Error( `Expected error message "${expectedMessage}", but no validation error was found. ` + - 'Ensure the validation step actually triggered an error.', + 'Ensure the validation step actually triggered an error.', ); }, ); @@ -214,7 +261,9 @@ Then('no listing should be created', async function (this: ShareThriftWorld) { let hasValidationError = false; try { - const storedError = await actor.answer(notes<{ lastValidationError?: string }>().get('lastValidationError')); + const storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); hasValidationError = !!storedError; } catch { // No error stored @@ -222,19 +271,23 @@ Then('no listing should be created', async function (this: ShareThriftWorld) { let listingId: string | undefined; try { - listingId = await actor.answer(notes<{ lastListingId?: string }>().get('lastListingId')); + listingId = await actor.answer( + notes<{ lastListingId?: string }>().get('lastListingId'), + ); } catch { // No listing ID — expected } if (listingId) { - throw new Error(`Expected no listing to be created, but one was created with id: ${listingId}`); + throw new Error( + `Expected no listing to be created, but one was created with id: ${listingId}`, + ); } if (!hasValidationError) { throw new Error( 'Expected a validation error to prevent listing creation, but no error was captured. ' + - 'The test may be passing without actually validating the scenario.', + 'The test may be passing without actually validating the scenario.', ); } }); diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts new file mode 100644 index 000000000..b66f304d2 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts @@ -0,0 +1,154 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { GraphQLClient } from '../../../../shared/abilities/graphql-client.ts'; +import { + DEFAULT_SHARING_PERIOD_DAYS, + ONE_DAY_MS, +} from '../../../../shared/support/domain-test-helpers.ts'; +import type { + ItemListingResponse, + ListingDetails, + ListingNotes, +} from '../../abilities/listing-types.ts'; + +const CREATE_LISTING_MUTATION = ` + mutation CreateItemListing($input: ItemListingCreateInput!) { + createItemListing(input: $input) { + status { success errorMessage } + listing { + id title description category location state + sharingPeriodStart sharingPeriodEnd images + } + } + } +`; + +const GET_LISTING_QUERY = ` + query GetListing($id: ObjectID!) { + itemListing(id: $id) { + id title description category location state + sharingPeriodStart sharingPeriodEnd images + } + } +`; + +export class CreateListing extends Task { + static with(details: ListingDetails) { + return new CreateListing(details); + } + + private constructor(private readonly details: ListingDetails) { + super(`creates listing "${details.title}" (api)`); + } + + async performAs(actor: Actor): Promise { + const graphql = GraphQLClient.as(actor); + + const isDraft = !( + this.details.isDraft === 'false' || this.details.isDraft === false + ); + + const response = await graphql.execute(CREATE_LISTING_MUTATION, { + input: { + title: this.details.title, + description: this.details.description, + category: this.details.category, + location: this.details.location, + sharingPeriodStart: this.calculateStartDate().toISOString(), + sharingPeriodEnd: this.calculateEndDate().toISOString(), + images: [], + isDraft, + }, + }); + + const mutationResult = response.data.createItemListing as Record< + string, + unknown + >; + const status = mutationResult.status as Record | undefined; + + if (status && !status.success) { + throw new Error( + String(status.errorMessage ?? 'Failed to create listing'), + ); + } + + const listing = this.deserializeListing( + (mutationResult.listing ?? {}) as Record, + ); + + if (!listing.id) { + throw new Error('API listing:create returned a listing without an id'); + } + if (listing.title !== this.details.title) { + throw new Error( + `API listing:create returned title "${listing.title}", expected "${this.details.title}"`, + ); + } + + const expectedState = isDraft ? 'draft' : 'active'; + if (this.normalizeStatus(listing.state) !== expectedState) { + throw new Error( + `API listing:create returned state "${listing.state}", expected a normalized state of "${expectedState}"`, + ); + } + + // Re-query to verify persistence + const persistedResponse = await graphql.execute(GET_LISTING_QUERY, { + id: listing.id, + }); + const persistedData = persistedResponse.data.itemListing as + | Record + | undefined; + if (!persistedData) { + throw new Error( + `Listing ${listing.id} was not found on re-query — API backend did not persist the listing`, + ); + } + const persisted = this.deserializeListing(persistedData); + if (persisted.title !== this.details.title) { + throw new Error( + `Re-queried listing title "${persisted.title}" does not match created title "${this.details.title}"`, + ); + } + + await actor.attemptsTo( + notes().set('lastListingId', listing.id), + notes().set('lastListingTitle', listing.title), + notes().set( + 'lastListingStatus', + this.normalizeStatus(listing.state), + ), + ); + } + + private calculateStartDate(): Date { + return new Date(Date.now() + ONE_DAY_MS); + } + + private calculateEndDate(): Date { + return new Date(Date.now() + ONE_DAY_MS * DEFAULT_SHARING_PERIOD_DAYS); + } + + private normalizeStatus(status: string): string { + const normalized = status.toLowerCase(); + return normalized === 'published' ? 'active' : normalized; + } + + private deserializeListing( + data: Record, + ): ItemListingResponse { + return { + id: String(data.id), + title: String(data.title), + description: String(data.description), + category: String(data.category), + location: String(data.location), + state: String(data.state) as ItemListingResponse['state'], + sharingPeriodStart: new Date(String(data.sharingPeriodStart)), + sharingPeriodEnd: new Date(String(data.sharingPeriodEnd)), + images: Array.isArray(data.images) ? data.images : [], + }; + } + + override toString = () => `creates listing "${this.details.title}" (api)`; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/domain/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/domain/create-listing.ts deleted file mode 100644 index 4e83b8376..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/domain/create-listing.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; -import type { ListingDetails, ListingNotes } from '../../abilities/listing-types.ts'; - -export type { ListingDetails }; - -export interface CreateListingInput { - title: string; - description: string; - category: string; - location: string; - state?: string; - isDraft?: boolean | string; -} -export class CreateListing extends Task { - static with(details: CreateListingInput) { - return new CreateListing(details); - } - - private constructor(private readonly details: CreateListingInput) { - super(`creates listing "${details.title}" (domain)`); - } - - async performAs(actor: Actor): Promise { - const ability = CreateListingAbility.as(actor); - // isDraft false → Active, true → Draft - const state = this.details.isDraft === 'false' || this.details.isDraft === false ? 'Active' : 'Draft'; - ability.createDraftListing({ - title: this.details.title, - description: this.details.description, - category: this.details.category, - location: this.details.location, - state, - }); - - const listing = ability.getCreatedListing(); - if (!listing) { - throw new Error('Domain CreateListingAbility.createDraftListing did not produce a listing'); - } - if (!listing.id) { - throw new Error('Domain CreateListingAbility produced a listing without an id'); - } - if (listing.title !== this.details.title) { - throw new Error( - `Domain listing title "${listing.title}" does not match input "${this.details.title}"`, - ); - } - if (!listing.state) { - throw new Error('Domain CreateListingAbility produced a listing without a state'); - } - if (listing.state !== state) { - throw new Error( - `Domain listing state "${listing.state}" does not match expected "${state}"`, - ); - } - - await actor.attemptsTo( - notes().set('lastListingId', listing.id), - notes().set('lastListingTitle', listing.title), - notes().set('lastListingStatus', listing.state.toLowerCase()), - ); - } - - override toString = () => `creates listing "${this.details.title}" (domain)`; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/session/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/session/create-listing.ts deleted file mode 100644 index d9368480f..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/session/create-listing.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import { getSession } from '../../../../shared/abilities/session.ts'; -import { ONE_DAY_MS, DEFAULT_SHARING_PERIOD_DAYS } from '../../../../shared/support/domain-test-helpers.ts'; -import type { ListingDetails, ListingNotes, CreateItemListingInput, ItemListingResponse } from '../../abilities/listing-types.ts'; - -export class CreateListing extends Task { - static with(details: ListingDetails) { - return new CreateListing(details); - } - - private constructor(private readonly details: ListingDetails) { - super(`creates listing "${details.title}" (session)`); - } - - async performAs(actor: Actor): Promise { - const session = getSession(actor, 'listing'); - - // isDraft false → draft false (Active) - const isDraft = !(this.details.isDraft === 'false' || this.details.isDraft === false); - - const listing = await session.execute('listing:create', { - title: this.details.title, - description: this.details.description, - category: this.details.category, - location: this.details.location, - sharingPeriodStart: this.calculateStartDate(), - sharingPeriodEnd: this.calculateEndDate(), - images: [], - isDraft, - }); - - // Validate the response contains expected data - if (!listing.id) { - throw new Error('Session listing:create returned a listing without an id'); - } - if (listing.title !== this.details.title) { - throw new Error( - `Session listing:create returned title "${listing.title}", expected "${this.details.title}"`, - ); - } - - const expectedState = isDraft ? 'draft' : 'active'; - if (this.normalizeStatus(listing.state) !== expectedState) { - throw new Error( - `Session listing:create returned state "${listing.state}", expected a normalized state of "${expectedState}"`, - ); - } - - // Re-query to verify persistence - const persisted = await session.execute<{ id: string }, ItemListingResponse | null>('listing:getById', { - id: listing.id, - }); - if (!persisted) { - throw new Error( - `Listing ${listing.id} was not found on re-query — session backend did not persist the listing`, - ); - } - if (persisted.title !== this.details.title) { - throw new Error( - `Re-queried listing title "${persisted.title}" does not match created title "${this.details.title}"`, - ); - } - - await actor.attemptsTo( - notes().set('lastListingId', listing.id), - notes().set('lastListingTitle', listing.title), - notes().set('lastListingStatus', this.normalizeStatus(listing.state)), - ); - - } - - private calculateStartDate(): Date { - return new Date(Date.now() + ONE_DAY_MS); - } - - private calculateEndDate(): Date { - return new Date(Date.now() + ONE_DAY_MS * DEFAULT_SHARING_PERIOD_DAYS); - } - - private normalizeStatus(status: ItemListingResponse['state']): string { - const normalized = status.toLowerCase(); - return normalized === 'published' ? 'active' : normalized; - } - - override toString = () => `creates listing "${this.details.title}" (session)`; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts index 972fd0dec..6150229a0 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts @@ -1,6 +1,12 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import type { ListingDetails, ListingNotes } from '../../abilities/listing-types.ts'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { ListingPage } from '@sthrift-verification/shared/pages'; import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; +import type { + ListingDetails, + ListingNotes, +} from '../../abilities/listing-types.ts'; + +const noop = () => undefined; export class CreateListing extends Task { static with(details: ListingDetails) { @@ -8,16 +14,19 @@ export class CreateListing extends Task { } private constructor(private readonly details: ListingDetails) { - super(`renders create listing UI "${details.title}"`); + super(`fills and submits create listing form "${details.title}"`); } async performAs(actor: Actor): Promise { - // 1. Perform domain validation first (same behavior as domain level) const isDraft = !( this.details.isDraft === 'false' || this.details.isDraft === false ); const state = isDraft ? 'draft' : 'active'; + // 1. Render and interact with UI via page object + await this.interactWithUI(isDraft); + + // 2. Domain validation (source of truth for test assertions) const ability = CreateListingAbility.as(actor); ability.createDraftListing({ title: this.details.title, @@ -34,70 +43,109 @@ export class CreateListing extends Task { ); } - // 2. Render UI components for code coverage + // 3. Store values in notes for assertion steps + await actor.attemptsTo( + notes().set('lastListingId', listing.id), + notes().set('lastListingTitle', listing.title), + notes().set('lastListingStatus', state), + ); + } + + private async interactWithUI(isDraft: boolean): Promise { const { ensureJsdom, cleanupJsdom } = await import( '../../../../shared/support/ui/jsdom-setup.ts' ); - const { renderForCoverage } = await import( - '../../../../shared/support/ui/react-render.tsx' - ); - ensureJsdom(); - // Render the CreateListing presentational component - const { CreateListing: CreateListingComponent } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx' - ); + try { + const React = await import('react'); + const { createElement } = React; + globalThis.React = React; + const { render, cleanup, act } = await import('@testing-library/react'); + const { MemoryRouter } = await import('react-router-dom'); + const { JsdomPageAdapter } = await import( + '@sthrift-verification/shared/pages/jsdom' + ); - const { cleanup } = await renderForCoverage( - CreateListingComponent as React.ComponentType>, - { - categories: [ - this.details.category ?? 'Other', - 'Electronics', - 'Sports', - ], - isLoading: false, - submissionStatus: 'idle' as const, - onSubmit: () => {}, - onCancel: () => {}, - uploadedImages: [], - onImageAdd: () => {}, - onImageRemove: () => {}, - onViewListing: () => {}, - onViewDraft: () => {}, - onModalClose: () => {}, - }, - { withRouter: true }, - ); + // Render the full CreateListing page component + const { CreateListing: CreateListingComponent } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx' + ); - cleanup(); - - // Also render the shared ListingForm from ui-components for coverage - const { ListingForm } = await import('@sthrift/ui-components'); - const { cleanup: cleanup2 } = await renderForCoverage( - ListingForm as React.ComponentType>, - { - categories: [this.details.category ?? 'Other', 'Electronics'], - isLoading: false, - maxCharacters: 2000, - handleFormSubmit: () => {}, - onCancel: () => {}, - }, - { withRouter: false }, - ); + const { container } = render( + createElement( + MemoryRouter, + null, + createElement( + CreateListingComponent as React.ComponentType< + Record + >, + { + categories: [ + ...new Set([ + this.details.category ?? 'Other', + 'Electronics', + 'Sports', + ]), + ], + isLoading: false, + submissionStatus: 'idle' as const, + onSubmit: noop, + onCancel: noop, + uploadedImages: [], + onImageAdd: noop, + onImageRemove: noop, + onViewListing: noop, + onViewDraft: noop, + onModalClose: noop, + }, + ), + ), + ); - cleanup2(); - cleanupJsdom(); + // Use shared page object for form interactions + const page = new ListingPage(new JsdomPageAdapter(container)); - // 3. Store values in notes for assertion steps - await actor.attemptsTo( - notes().set('lastListingId', listing.id), - notes().set('lastListingTitle', listing.title), - notes().set('lastListingStatus', state), - ); + await act(async () => { + await page.fillForm({ + title: this.details.title, + description: this.details.description, + location: this.details.location, + category: this.details.category, + }); + }); + + await act(async () => { + if (isDraft) { + await page.clickSaveDraft(); + } else { + await page.clickPublish(); + } + }); + + // Also render the shared ListingForm standalone for ui-components coverage + const { ListingForm } = await import('@sthrift/ui-components'); + render( + createElement( + ListingForm as React.ComponentType>, + { + categories: [ + ...new Set([this.details.category ?? 'Other', 'Electronics']), + ], + isLoading: false, + maxCharacters: 2000, + handleFormSubmit: noop, + onCancel: noop, + }, + ), + ); + + cleanup(); + } finally { + cleanupJsdom(); + } } override toString = () => - `renders create listing UI "${this.details.title}"`; + `fills and submits create listing form "${this.details.title}"`; } diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/api-reservation-request-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/api-reservation-request-session.ts deleted file mode 100644 index 409855914..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/api-reservation-request-session.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ApiSession } from '../../../shared/abilities/api-session.ts'; -import type { CreateReservationRequestInput, ReservationRequestResponse } from './reservation-request-types.ts'; - -export abstract class ApiReservationRequestSession extends ApiSession { - context = 'reservation'; - - constructor(apiUrl: string, authToken?: string) { - super(apiUrl, authToken); - this.registerOperations(); - } - - protected registerOperations(): void { - this.registerOperation('reservation:create', (input) => - this.handleCreateReservationRequest(input as CreateReservationRequestInput), - ); - this.registerOperation('reservation:getCountForListing', (input) => - this.handleGetCountForListing(input as { listingId: string }), - ); - } - - createReservationRequest(input: CreateReservationRequestInput): Promise { - return this.execute( - 'reservation:create', - input, - ); - } - - getReservationRequestCountForListing(listingId: string): Promise { - return this.execute<{ listingId: string }, number>( - 'reservation:getCountForListing', - { listingId }, - ); - } - - protected async handleCreateReservationRequest( - input: CreateReservationRequestInput, - ): Promise { - const mutation = ` - mutation CreateReservationRequest($input: ReservationRequestCreateInput!) { - createReservationRequest(input: $input) { - status { - success - errorMessage - } - reservationRequest { - id - state - reservationPeriodStart - reservationPeriodEnd - listing { - id - } - reserver { - ... on PersonalUser { id } - ... on AdminUser { id } - } - createdAt - updatedAt - } - } - } - `; - - const response = await this.executeGraphQL(mutation, { - input: { - listingId: input.listingId, - reservationPeriodStart: input.reservationPeriodStart.toISOString(), - reservationPeriodEnd: input.reservationPeriodEnd.toISOString(), - }, - }); - - const mutationResult = response.data['createReservationRequest'] as Record; - const status = mutationResult['status'] as Record | undefined; - - if (status && !status['success']) { - throw new Error(String(status['errorMessage'] ?? 'Failed to create reservation request')); - } - - const data = (mutationResult['reservationRequest'] ?? {}) as Record; - return this.deserializeReservationRequest(data, input); - } - - protected async handleGetCountForListing(input: { - listingId: string; - }): Promise { - const query = ` - query GetReservationRequestsForListing($listingId: ObjectID!) { - queryActiveByListingId(listingId: $listingId) { - id - } - } - `; - - const response = await this.executeGraphQL(query, { listingId: input.listingId }); - const items = response.data['queryActiveByListingId'] as Record[]; - return Array.isArray(items) ? items.length : 0; - } - - protected deserializeReservationRequest( - data: Record, - originalInput?: CreateReservationRequestInput, - ): ReservationRequestResponse { - const listing = data['listing'] as Record | undefined; - const reserver = data['reserver'] as Record | undefined; - - return { - id: String(data['id']), - listingId: listing ? String(listing['id']) : (originalInput?.listingId ?? ''), - reserver: originalInput?.reserver ?? { - id: reserver ? String(reserver['id']) : '', - email: '', - firstName: '', - lastName: '', - }, - reservationPeriodStart: data['reservationPeriodStart'] ? new Date(String(data['reservationPeriodStart'])) : new Date(), - reservationPeriodEnd: data['reservationPeriodEnd'] ? new Date(String(data['reservationPeriodEnd'])) : new Date(), - state: String(data['state']) as ReservationRequestResponse['state'], - createdAt: data['createdAt'] ? new Date(String(data['createdAt'])) : new Date(), - updatedAt: data['updatedAt'] ? new Date(String(data['updatedAt'])) : new Date(), - }; - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts index 170b69825..9a1fb5d88 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts @@ -1,7 +1,7 @@ import { Ability } from '@serenity-js/core'; import { Domain } from '@sthrift/domain'; import { makeReservationRequestProps, makeListingReference, makeSharerUser } from '../../../shared/support/domain-test-helpers.ts'; -import { reservationRequests } from '../../../shared/support/test-data/reservation-request.test-data.ts'; +import { reservationRequests } from '@sthrift-verification/shared/test-data'; type Passport = Domain.Passport; type ReservationRequestProps = Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestProps; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/graphql-reservation-request-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/graphql-reservation-request-session.ts deleted file mode 100644 index 73f248977..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/graphql-reservation-request-session.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ApiReservationRequestSession } from './api-reservation-request-session.ts'; - -export class GraphQLReservationRequestSession extends ApiReservationRequestSession {} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/mongo-reservation-request-session.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/mongo-reservation-request-session.ts deleted file mode 100644 index d6ad55575..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/mongo-reservation-request-session.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ApiReservationRequestSession } from './api-reservation-request-session.ts'; - -export class MongoReservationRequestSession extends ApiReservationRequestSession {} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts index e5d863431..668b6bb3f 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts @@ -1,8 +1,16 @@ import { type Actor, Question } from '@serenity-js/core'; -import { getSession } from '../../../shared/abilities/session.ts'; +import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; -export class GetReservationRequestCountForListing extends Question> { +const GET_RESERVATION_COUNT_QUERY = ` + query GetReservationRequestsForListing($listingId: ObjectID!) { + queryActiveByListingId(listingId: $listingId) { id } + } +`; + +export class GetReservationRequestCountForListing extends Question< + Promise +> { static forListing(listingId: string) { return new GetReservationRequestCountForListing(listingId); } @@ -11,11 +19,12 @@ export class GetReservationRequestCountForListing extends Question { - const session = getSession(actor, 'reservation'); - return session.execute<{ listingId: string }, number>( - 'reservation:getCountForListing', - { listingId: this.listingId }, - ); + async answeredBy(actor: Actor): Promise { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(GET_RESERVATION_COUNT_QUERY, { + listingId: this.listingId, + }); + const items = response.data.queryActiveByListingId as unknown[]; + return Array.isArray(items) ? items.length : 0; } } diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts index da749e49f..6e0580110 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts @@ -1,45 +1,38 @@ -import { Given, Then, When, type DataTable } from '@cucumber/cucumber'; -import { actorCalled, notes } from '@serenity-js/core'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { Ensure, equals, includes, isPresent } from '@serenity-js/assertions'; +import { actorCalled, notes } from '@serenity-js/core'; +import { + makeTestUserData, + resolveActorName, +} from '../../../shared/support/domain-test-helpers.ts'; import type { ShareThriftWorld } from '../../../world.ts'; -import { makeTestUserData, resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; -import { CreateListing as SessionCreateListing } from '../../listing/tasks/session/create-listing.ts'; -import { CreateListing as DomainCreateListing, type CreateListingInput } from '../../listing/tasks/domain/create-listing.ts'; +import type { ListingDetails } from '../../listing/abilities/listing-types.ts'; +import { CreateListing as ApiCreateListing } from '../../listing/tasks/api/create-listing.ts'; import { CreateListing as UICreateListing } from '../../listing/tasks/ui/create-listing.ts'; -import { CreateReservationRequest as SessionCreateReservationRequest } from '../tasks/session/create-reservation-request.ts'; -import { CreateReservationRequest as DomainCreateReservationRequest } from '../tasks/domain/create-reservation-request.ts'; -import { CreateReservationRequest as UICreateReservationRequest } from '../tasks/ui/create-reservation-request.ts'; -import { GetReservationRequestCountForListing } from '../questions/get-reservation-request-count-for-listing.ts'; +import type { + CreateReservationRequestInput, + ReservationRequestNotes, +} from '../abilities/reservation-request-types.ts'; import { DomainGetReservationRequestCountForListing } from '../questions/domain-get-reservation-request-count-for-listing.ts'; -import type { CreateReservationRequestInput, ReservationRequestNotes } from '../abilities/reservation-request-types.ts'; +import { GetReservationRequestCountForListing } from '../questions/get-reservation-request-count-for-listing.ts'; +import { CreateReservationRequest as ApiCreateReservationRequest } from '../tasks/api/create-reservation-request.ts'; +import { CreateReservationRequest as UICreateReservationRequest } from '../tasks/ui/create-reservation-request.ts'; let lastActorName = 'Alice'; function getCreateListingTask(level: string) { - switch (level) { - case 'session': - return SessionCreateListing; - case 'ui': - return UICreateListing; - default: - return DomainCreateListing; - } + if (level === 'api') return ApiCreateListing; + return UICreateListing; } function getCreateReservationRequestTask(level: string) { - switch (level) { - case 'session': - return SessionCreateReservationRequest; - case 'ui': - return UICreateReservationRequest; - default: - return DomainCreateReservationRequest; - } + if (level === 'api') return ApiCreateReservationRequest; + return UICreateReservationRequest; } function parseDateInput(input: string): Date { if (input.startsWith('+')) { - const days = parseInt(input.substring(1), 10); + const days = Number.parseInt(input.substring(1), 10); const date = new Date(); date.setDate(date.getDate() + days); date.setHours(0, 0, 0, 0); @@ -69,7 +62,11 @@ async function getListingIdFromOwner(ownerName: string): Promise { Given( '{word} has created a listing with:', - async function (this: ShareThriftWorld, actorName: string, dataTable: DataTable) { + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { lastActorName = actorName; const actor = actorCalled(actorName); const details = dataTable.rowsHash(); @@ -77,29 +74,40 @@ Given( const CreateListing = getCreateListingTask(this.level); await actor.attemptsTo( - CreateListing.with(details as unknown as CreateListingInput), + CreateListing.with(details as unknown as ListingDetails), ); }, ); When( - '{word} creates a reservation request for {word}\'s listing with:', - async function (this: ShareThriftWorld, reserver: string, owner: string, dataTable: DataTable) { + "{word} creates a reservation request for {word}'s listing with:", + async function ( + this: ShareThriftWorld, + reserver: string, + owner: string, + dataTable: DataTable, + ) { lastActorName = reserver; const actor = actorCalled(reserver); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask(this.level); + const CreateReservationRequest = getCreateReservationRequestTask( + this.level, + ); const listingId = await getListingIdFromOwner(owner); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ listingId, - reservationPeriodStart: startDate ? parseDateInput(String(startDate)) : new Date(), - reservationPeriodEnd: endDate ? parseDateInput(String(endDate)) : new Date(), + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), reserver: makeTestUserData(reserver), }), ); @@ -108,22 +116,37 @@ When( When( '{word} attempts to create a reservation request with:', - async function (this: ShareThriftWorld, actorName: string, dataTable: DataTable) { + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { lastActorName = actorName; const actor = actorCalled(actorName); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask(this.level); + const CreateReservationRequest = getCreateReservationRequestTask( + this.level, + ); await actor.attemptsTo( - notes().set('lastReservationRequestId', undefined as unknown as string), - notes().set('lastReservationRequestState', undefined as unknown as string), - notes().set('lastValidationError', undefined as unknown as string), + notes().set( + 'lastReservationRequestId', + undefined as unknown as string, + ), + notes().set( + 'lastReservationRequestState', + undefined as unknown as string, + ), + notes().set( + 'lastValidationError', + undefined as unknown as string, + ), ); try { - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; const listingId = await getListingIdFromOwner('Bob'); @@ -143,9 +166,13 @@ When( CreateReservationRequest.with(input as CreateReservationRequestInput), ); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); await actor.attemptsTo( - notes().set('lastValidationError', errorMessage), + notes().set( + 'lastValidationError', + errorMessage, + ), ); } }, @@ -233,19 +260,30 @@ Then( Then( '{word} should see a reservation error for {string}', - async function (this: ShareThriftWorld, actorName: string, fieldName: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + fieldName: string, + ) { const resolvedActorName = resolveActorName(actorName); const actor = actorCalled(resolvedActorName); - const storedError = await actor.answer(notes<{lastValidationError?: string}>().get('lastValidationError')); + const storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); if (!storedError) { - throw new Error(`Expected a validation error for "${fieldName}" but no error was captured`); + throw new Error( + `Expected a validation error for "${fieldName}" but no error was captured`, + ); } const lowerError = storedError.toLowerCase(); const lowerField = fieldName.toLowerCase(); const isFieldMentioned = lowerError.includes(lowerField); - const isValidationPattern = /required|missing|invalid|cannot read properties of undefined|wrong raw value type/i.test(storedError); + const isValidationPattern = + /required|missing|invalid|cannot read properties of undefined|wrong raw value type/i.test( + storedError, + ); if (!isFieldMentioned && !isValidationPattern) { throw new Error( @@ -255,14 +293,16 @@ Then( let requestId: string | undefined; try { - requestId = await actor.answer(notes().get('lastReservationRequestId')); + requestId = await actor.answer( + notes().get('lastReservationRequestId'), + ); } catch { // expected } if (requestId) { throw new Error( `Expected reservation creation to be blocked by "${fieldName}" validation, ` + - `but a request was created with id: ${requestId}`, + `but a request was created with id: ${requestId}`, ); } }, @@ -270,13 +310,17 @@ Then( Then( '{word} should see a reservation error {string}', - async function (this: ShareThriftWorld, actorName: string, expectedMessage: string) { + async function ( + this: ShareThriftWorld, + actorName: string, + expectedMessage: string, + ) { const resolvedActorName = resolveActorName(actorName); const actor = actorCalled(resolvedActorName); await actor.attemptsTo( Ensure.that( - notes<{lastValidationError: string}>().get('lastValidationError'), + notes<{ lastValidationError: string }>().get('lastValidationError'), includes(expectedMessage), ), ); @@ -290,7 +334,9 @@ Then( let hasValidationError = false; try { - const storedError = await actor.answer(notes().get('lastValidationError')); + const storedError = await actor.answer( + notes().get('lastValidationError'), + ); hasValidationError = !!storedError; } catch { // No error stored @@ -298,7 +344,9 @@ Then( let requestId: string | undefined; try { - requestId = await actor.answer(notes().get('lastReservationRequestId')); + requestId = await actor.answer( + notes().get('lastReservationRequestId'), + ); } catch { // No ID — expected } @@ -312,7 +360,7 @@ Then( if (!hasValidationError) { throw new Error( 'Expected a validation error to prevent reservation creation, but no error was captured. ' + - 'The test may be passing without actually validating the scenario.', + 'The test may be passing without actually validating the scenario.', ); } }, @@ -323,34 +371,44 @@ Then( async function (this: ShareThriftWorld) { const actor = actorCalled(lastActorName); const listingId = await getListingIdFromOwner('Bob'); - const countQuestion = this.level === 'domain' || this.level === 'ui' - ? DomainGetReservationRequestCountForListing.forListing(listingId) - : GetReservationRequestCountForListing.forListing(listingId); + const countQuestion = + this.level === 'ui' + ? DomainGetReservationRequestCountForListing.forListing(listingId) + : GetReservationRequestCountForListing.forListing(listingId); - await actor.attemptsTo( - Ensure.that(countQuestion, equals(1)), - ); + await actor.attemptsTo(Ensure.that(countQuestion, equals(1))); }, ); Given( - '{word} has already created a reservation request for {word}\'s listing with:', - async function (this: ShareThriftWorld, reserver: string, owner: string, dataTable: DataTable) { + "{word} has already created a reservation request for {word}'s listing with:", + async function ( + this: ShareThriftWorld, + reserver: string, + owner: string, + dataTable: DataTable, + ) { lastActorName = reserver; const actor = actorCalled(reserver); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask(this.level); + const CreateReservationRequest = getCreateReservationRequestTask( + this.level, + ); const listingId = await getListingIdFromOwner(owner); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ listingId, - reservationPeriodStart: startDate ? parseDateInput(String(startDate)) : new Date(), - reservationPeriodEnd: endDate ? parseDateInput(String(endDate)) : new Date(), + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), reserver: makeTestUserData(reserver), }), ); @@ -359,27 +417,40 @@ Given( When( '{word} attempts to create another reservation request for the same listing with:', - async function (this: ShareThriftWorld, actorName: string, dataTable: DataTable) { + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { lastActorName = actorName; const actor = actorCalled(actorName); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask(this.level); + const CreateReservationRequest = getCreateReservationRequestTask( + this.level, + ); await actor.attemptsTo( - notes<{lastValidationError?: string}>().set('lastValidationError', undefined as unknown as string), + notes<{ lastValidationError?: string }>().set( + 'lastValidationError', + undefined as unknown as string, + ), ); try { const listingId = await getListingIdFromOwner('Bob'); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ listingId, - reservationPeriodStart: startDate ? parseDateInput(String(startDate)) : new Date(), - reservationPeriodEnd: endDate ? parseDateInput(String(endDate)) : new Date(), + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), reserver: { id: 'test-user-1', email: `${actorName.toLowerCase()}@test.com`, @@ -389,8 +460,14 @@ When( }), ); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - await actor.attemptsTo(notes<{lastValidationError?: string}>().set('lastValidationError', errorMessage)); + const errorMessage = + error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes<{ lastValidationError?: string }>().set( + 'lastValidationError', + errorMessage, + ), + ); } }, ); diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts new file mode 100644 index 000000000..ee17d9180 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts @@ -0,0 +1,155 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { GraphQLClient } from '../../../../shared/abilities/graphql-client.ts'; +import type { + CreateReservationRequestInput, + ReservationRequestNotes, + ReservationRequestResponse, +} from '../../abilities/reservation-request-types.ts'; + +const CREATE_RESERVATION_REQUEST_MUTATION = ` + mutation CreateReservationRequest($input: ReservationRequestCreateInput!) { + createReservationRequest(input: $input) { + status { success errorMessage } + reservationRequest { + id state reservationPeriodStart reservationPeriodEnd + listing { id } + reserver { ... on PersonalUser { id } ... on AdminUser { id } } + createdAt updatedAt + } + } + } +`; + +const GET_RESERVATION_COUNT_QUERY = ` + query GetReservationRequestsForListing($listingId: ObjectID!) { + queryActiveByListingId(listingId: $listingId) { id } + } +`; + +export class CreateReservationRequest extends Task { + static with(input: CreateReservationRequestInput) { + return new CreateReservationRequest(input); + } + + private constructor(private readonly input: CreateReservationRequestInput) { + super(`creates reservation request for listing "${input.listingId}" (api)`); + } + + async performAs(actor: Actor): Promise { + const graphql = GraphQLClient.as(actor); + + const response = await graphql.execute( + CREATE_RESERVATION_REQUEST_MUTATION, + { + input: { + listingId: this.input.listingId, + reservationPeriodStart: + this.input.reservationPeriodStart.toISOString(), + reservationPeriodEnd: this.input.reservationPeriodEnd.toISOString(), + }, + }, + ); + + const mutationResult = response.data.createReservationRequest as Record< + string, + unknown + >; + const status = mutationResult.status as Record | undefined; + + if (status && !status.success) { + throw new Error( + String(status.errorMessage ?? 'Failed to create reservation request'), + ); + } + + const data = (mutationResult.reservationRequest ?? {}) as Record< + string, + unknown + >; + const reservationRequest = this.deserialize(data); + + if (!reservationRequest.id) { + throw new Error( + 'API reservation:create returned a reservation request without an id', + ); + } + if (!reservationRequest.state) { + throw new Error( + 'API reservation:create returned a reservation request without a state', + ); + } + if (reservationRequest.state !== 'Requested') { + throw new Error( + `API reservation:create returned state "${reservationRequest.state}", expected "Requested"`, + ); + } + + // Verify persistence via count query + const countResponse = await graphql.execute(GET_RESERVATION_COUNT_QUERY, { + listingId: this.input.listingId, + }); + const items = countResponse.data.queryActiveByListingId as unknown[]; + const count = Array.isArray(items) ? items.length : 0; + + if (count < 1) { + throw new Error( + `Expected at least 1 reservation request for listing ${this.input.listingId} after creation, but found ${count}`, + ); + } + + const startDate = + reservationRequest.reservationPeriodStart.toISOString().split('T')[0] ?? + ''; + const endDate = + reservationRequest.reservationPeriodEnd.toISOString().split('T')[0] ?? ''; + + await actor.attemptsTo( + notes().set( + 'lastReservationRequestId', + reservationRequest.id, + ), + notes().set( + 'lastReservationRequestState', + reservationRequest.state, + ), + notes().set( + 'lastReservationRequestStartDate', + startDate, + ), + notes().set( + 'lastReservationRequestEndDate', + endDate, + ), + ); + } + + private deserialize( + data: Record, + ): ReservationRequestResponse { + const listing = data.listing as Record | undefined; + const reserver = data.reserver as Record | undefined; + + return { + id: String(data.id), + listingId: listing ? String(listing.id) : this.input.listingId, + reserver: this.input.reserver ?? { + id: reserver ? String(reserver.id) : '', + email: '', + firstName: '', + lastName: '', + }, + reservationPeriodStart: data.reservationPeriodStart + ? new Date(String(data.reservationPeriodStart)) + : new Date(), + reservationPeriodEnd: data.reservationPeriodEnd + ? new Date(String(data.reservationPeriodEnd)) + : new Date(), + state: String(data.state) as ReservationRequestResponse['state'], + createdAt: data.createdAt ? new Date(String(data.createdAt)) : new Date(), + updatedAt: data.updatedAt ? new Date(String(data.updatedAt)) : new Date(), + }; + } + + override toString = () => + `creates reservation request for listing "${this.input.listingId}" (api)`; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/domain/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/domain/create-reservation-request.ts deleted file mode 100644 index f49e5cc54..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/domain/create-reservation-request.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import { CreateReservationRequestAbility } from '../../abilities/create-reservation-request-ability.ts'; -import type { CreateReservationRequestInput } from '../../abilities/reservation-request-types.ts'; - -interface ReservationRequestNotes { - lastReservationRequestId: string; - lastReservationRequestState: string; - lastReservationRequestStartDate: string; - lastReservationRequestEndDate: string; -} - -export class CreateReservationRequest extends Task { - static with(input: CreateReservationRequestInput) { - return new CreateReservationRequest(input); - } - - private constructor(private readonly input: CreateReservationRequestInput) { - super(`creates reservation request for listing "${input.listingId}" (domain)`); - } - - async performAs(actor: Actor): Promise { - const ability = CreateReservationRequestAbility.as(actor); - ability.createReservationRequest(this.input); - - const reservationRequest = ability.getCreatedAggregate(); - if (!reservationRequest) { - throw new Error('Domain CreateReservationRequestAbility.createReservationRequest did not produce an aggregate'); - } - if (!reservationRequest.id) { - throw new Error('Domain reservation request aggregate has no id'); - } - if (!reservationRequest.state) { - throw new Error('Domain reservation request aggregate has no state'); - } - if (reservationRequest.state !== 'Requested') { - throw new Error( - `Domain reservation request state "${reservationRequest.state}" does not match expected "Requested"`, - ); - } - - const startDate = reservationRequest.reservationPeriodStart.toISOString().split('T')[0] ?? ''; - const endDate = reservationRequest.reservationPeriodEnd.toISOString().split('T')[0] ?? ''; - - await actor.attemptsTo( - notes().set('lastReservationRequestId', reservationRequest.id), - notes().set('lastReservationRequestState', reservationRequest.state), - notes().set( - 'lastReservationRequestStartDate', - startDate, - ), - notes().set( - 'lastReservationRequestEndDate', - endDate, - ), - ); - } - - override toString = () => `creates reservation request for listing "${this.input.listingId}" (domain)`; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/session/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/session/create-reservation-request.ts deleted file mode 100644 index e4902fd31..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/session/create-reservation-request.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import { getSession } from '../../../../shared/abilities/session.ts'; -import type { CreateReservationRequestInput, ReservationRequestNotes, ReservationRequestResponse } from '../../abilities/reservation-request-types.ts'; - -export class CreateReservationRequest extends Task { - static with(input: CreateReservationRequestInput) { - return new CreateReservationRequest(input); - } - - private constructor(private readonly input: CreateReservationRequestInput) { - super(`creates reservation request for listing "${input.listingId}"`); - } - - async performAs(actor: Actor): Promise { - const session = getSession(actor, 'reservation'); - - const reservationRequest = await session.execute( - 'reservation:create', - this.input, - ); - - // Validate the response contains expected data - if (!reservationRequest.id) { - throw new Error('Session reservation:create returned a reservation request without an id'); - } - if (!reservationRequest.state) { - throw new Error('Session reservation:create returned a reservation request without a state'); - } - if (reservationRequest.state !== 'Requested') { - throw new Error( - `Session reservation:create returned state "${reservationRequest.state}", expected "Requested"`, - ); - } - - // Verify persistence via count query - const count = await session.execute<{ listingId: string }, number>( - 'reservation:getCountForListing', - { listingId: this.input.listingId }, - ); - if (count < 1) { - throw new Error( - `Expected at least 1 reservation request for listing ${this.input.listingId} after creation, but found ${count}`, - ); - } - - const startDate = reservationRequest.reservationPeriodStart.toISOString().split('T')[0] ?? ''; - const endDate = reservationRequest.reservationPeriodEnd.toISOString().split('T')[0] ?? ''; - - await actor.attemptsTo( - notes().set('lastReservationRequestId', reservationRequest.id), - notes().set('lastReservationRequestState', reservationRequest.state), - notes().set( - 'lastReservationRequestStartDate', - startDate, - ), - notes().set( - 'lastReservationRequestEndDate', - endDate, - ), - ); - - } - - override toString = () => `creates reservation request for listing "${this.input.listingId}"`; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts index 4dde4eec5..017197173 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts @@ -1,6 +1,12 @@ -import { Task, type Actor, notes } from '@serenity-js/core'; -import type { CreateReservationRequestInput, ReservationRequestNotes } from '../../abilities/reservation-request-types.ts'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { ReservationPage } from '@sthrift-verification/shared/pages'; import { CreateReservationRequestAbility } from '../../abilities/create-reservation-request-ability.ts'; +import type { + CreateReservationRequestInput, + ReservationRequestNotes, +} from '../../abilities/reservation-request-types.ts'; + +const noop = () => undefined; export class CreateReservationRequest extends Task { static with(input: CreateReservationRequestInput) { @@ -9,12 +15,15 @@ export class CreateReservationRequest extends Task { private constructor(private readonly input: CreateReservationRequestInput) { super( - `renders reservation request UI for listing "${input.listingId}"`, + `fills and submits reservation request form for listing "${input.listingId}"`, ); } async performAs(actor: Actor): Promise { - // 1. Perform domain validation first (handles overlaps, missing fields, etc.) + // 1. Render and interact with UI via page object + await this.interactWithUI(); + + // 2. Domain validation (source of truth for test assertions) const ability = CreateReservationRequestAbility.as(actor); ability.createReservationRequest(this.input); @@ -25,86 +34,12 @@ export class CreateReservationRequest extends Task { ); } - // 2. Render UI components for code coverage - const { ensureJsdom, cleanupJsdom } = await import( - '../../../../shared/support/ui/jsdom-setup.ts' - ); - const { renderForCoverage } = await import( - '../../../../shared/support/ui/react-render.tsx' - ); - - ensureJsdom(); - - // Render the ReservationRequestForm component - const { ReservationRequestForm } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx' - ); - - const { cleanup } = await renderForCoverage( - ReservationRequestForm as React.ComponentType>, - { - userIsSharer: false, - isAuthenticated: true, - userReservationRequest: null, - onReserveClick: () => {}, - onCancelClick: () => {}, - reservationDates: { - startDate: this.input.reservationPeriodStart, - endDate: this.input.reservationPeriodEnd, - }, - onReservationDatesChange: () => {}, - reservationLoading: false, - otherReservationsLoading: false, - otherReservationsError: undefined, - otherReservations: [], - }, - { withRouter: true }, - ); - - cleanup(); - - // Also render the ReservationCard for broader coverage - try { - const { ReservationCard } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx' - ); - - const { cleanup: cleanup2 } = await renderForCoverage( - ReservationCard as React.ComponentType>, - { - reservation: { - id: this.input.listingId, - listing: { - title: 'Test Listing', - images: [], - }, - state: 'Requested', - reservationPeriodStart: - this.input.reservationPeriodStart.toISOString(), - reservationPeriodEnd: - this.input.reservationPeriodEnd.toISOString(), - }, - showActions: false, - }, - { withRouter: true }, - ); - - cleanup2(); - } catch { - // ReservationCard may have additional import requirements; skip gracefully - } - - cleanupJsdom(); - // 3. Store values in notes for assertion steps const startDate = - reservationRequest.reservationPeriodStart - .toISOString() - .split('T')[0] ?? ''; + reservationRequest.reservationPeriodStart.toISOString().split('T')[0] ?? + ''; const endDate = - reservationRequest.reservationPeriodEnd - .toISOString() - .split('T')[0] ?? ''; + reservationRequest.reservationPeriodEnd.toISOString().split('T')[0] ?? ''; await actor.attemptsTo( notes().set( @@ -126,6 +61,107 @@ export class CreateReservationRequest extends Task { ); } + private async interactWithUI(): Promise { + const { ensureJsdom, cleanupJsdom } = await import( + '../../../../shared/support/ui/jsdom-setup.ts' + ); + ensureJsdom(); + + try { + const React = await import('react'); + const { createElement } = React; + globalThis.React = React; + const { render, cleanup, act } = await import('@testing-library/react'); + const { MemoryRouter } = await import('react-router-dom'); + const { JsdomPageAdapter } = await import( + '@sthrift-verification/shared/pages/jsdom' + ); + + // Render the ReservationRequestForm component + const { ReservationRequestForm } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx' + ); + + const { container } = render( + createElement( + MemoryRouter, + null, + createElement( + ReservationRequestForm as React.ComponentType< + Record + >, + { + userIsSharer: false, + isAuthenticated: true, + userReservationRequest: null, + onReserveClick: noop, + onCancelClick: noop, + reservationDates: { + startDate: this.input.reservationPeriodStart, + endDate: this.input.reservationPeriodEnd, + }, + onReservationDatesChange: noop, + reservationLoading: false, + otherReservationsLoading: false, + otherReservationsError: undefined, + otherReservations: [], + }, + ), + ), + ); + + // Use shared page object for form interactions + const page = new ReservationPage(new JsdomPageAdapter(container)); + + await act(async () => { + await page.openDatePicker(); + }); + + // Click the Reserve button + await act(async () => { + await page.clickReserve(); + }); + + // Render ReservationCard for broader coverage + try { + const { ReservationCard } = await import( + '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx' + ); + + render( + createElement( + MemoryRouter, + null, + createElement( + ReservationCard as React.ComponentType>, + { + reservation: { + id: this.input.listingId, + listing: { + title: 'Test Listing', + images: [], + }, + state: 'Requested', + reservationPeriodStart: + this.input.reservationPeriodStart?.toISOString(), + reservationPeriodEnd: + this.input.reservationPeriodEnd?.toISOString(), + }, + showActions: false, + }, + ), + ), + ); + } catch { + // ReservationCard may have additional import requirements + } + + cleanup(); + } finally { + cleanupJsdom(); + } + } + override toString = () => - `renders reservation request UI for listing "${this.input.listingId}"`; + `fills and submits reservation request form for listing "${this.input.listingId}"`; } diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/api-session.ts b/packages/sthrift-verification/acceptance-tests/src/shared/abilities/api-session.ts deleted file mode 100644 index 515d804a7..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/api-session.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Ability } from '@serenity-js/core'; -import type { Session, OperationInput, OperationResult } from './session.ts'; - -type ApiOperationHandler = (input: OperationInput) => Promise; - -interface ApiResponseData { - data: Record; - errors?: Array<{ message: string }>; -} - -export class ApiSession extends Ability implements Session { - private readonly operationHandlers = new Map(); - - constructor( - private readonly apiUrl: string, - private readonly authToken?: string, - ) { - super(); - } - - static at(apiUrl: string, authToken?: string): ApiSession { - return new ApiSession(apiUrl, authToken); - } - - registerOperation( - operationName: string, - handler: ApiOperationHandler, - ): void { - this.operationHandlers.set(operationName, handler); - } - - execute( - operationName: string, - input: TInput, - ): Promise { - const handler = this.operationHandlers.get(operationName); - if (!handler) { - return Promise.reject( - new Error(`Operation not registered: '${operationName}'. Available operations: ${Array.from(this.operationHandlers.keys()).join(', ')}`), - ); - } - return handler(input as OperationInput) as Promise; - } - - async executeGraphQL( - query: string, - variables: Record, - ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - }; - if (this.authToken) { - headers['Authorization'] = `Bearer ${this.authToken}`; - } - - const response = await fetch(this.apiUrl, { - method: 'POST', - headers, - body: JSON.stringify({ query, variables }), - }); - - const result = (await response.json()) as ApiResponseData; - - // GraphQL errors may come with 200 OK - if (result.errors && Array.isArray(result.errors)) { - const errorMessage = result.errors - .map((err: { message?: string }) => err.message ?? 'Unknown error') - .join('; '); - throw new Error(errorMessage); - } - - if (!response.ok) { - throw new Error(`GraphQL error: ${response.status} ${response.statusText}`); - } - - return result; - } - -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts b/packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts new file mode 100644 index 000000000..e862ad2e7 --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts @@ -0,0 +1,51 @@ +import { Ability, type Actor } 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); + } + + static as(actor: Actor): GraphQLClient { + return actor.abilityTo(GraphQLClient); + } + + 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; + } +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/multi-context-session.ts b/packages/sthrift-verification/acceptance-tests/src/shared/abilities/multi-context-session.ts deleted file mode 100644 index 94847bd8a..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/multi-context-session.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Ability } from '@serenity-js/core'; -import type { Session, OperationInput, OperationResult } from './session.ts'; - -// Routes operations to context-specific sessions (e.g., 'listing:create' → listing session) -export class MultiContextSession extends Ability implements Session { - private readonly sessions = new Map(); - - registerSession(context: string, session: Session): void { - this.sessions.set(context, session); - } - - execute( - operationName: string, - input: TInput, - ): Promise { - // Extract context from operation name (e.g., 'listing:create' -> 'listing') - const [context] = operationName.split(':'); - - const session = this.sessions.get(context ?? ''); - if (!session) { - const availableContexts = Array.from(this.sessions.keys()).join(', '); - return Promise.reject( - new Error( - `No session registered for context '${context}'. Available contexts: ${availableContexts}`, - ), - ); - } - - return session.execute(operationName, input); - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/session.ts b/packages/sthrift-verification/acceptance-tests/src/shared/abilities/session.ts deleted file mode 100644 index 1c06201f1..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/session.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Actor } from '@serenity-js/core'; - -export type OperationInput = object; -export type OperationResult = object | string | number | boolean | null; - -export interface Session { - context?: string; - - execute( - operationName: string, - input: TInput, - ): Promise; -} - -export function getSession(actor: Actor, contextHint?: string): Session { - // Accessing private `abilities` map — requires type assertion to cross Serenity.js internal boundary - const actorAbilities = (actor as unknown as { abilities: Map }).abilities; - const sessions: Array<[Function, Session]> = []; - - const entries = Array.from(actorAbilities.entries()); - for (const [key, ability] of entries) { - if ('execute' in (ability as object)) { - sessions.push([key, ability as Session]); - } - } - - if (sessions.length === 0) { - throw new Error('Actor does not have a Session ability'); - } - - if (contextHint && sessions.length > 1) { - const hintedSession = sessions.find(([_, session]) => { - return session.context?.toLowerCase() === contextHint.toLowerCase(); - }); - if (hintedSession) { - return hintedSession[1]; - } - } - - const session = sessions[0]; - if (!session) { - throw new Error('No session found'); - } - return session[1]; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts index c79047c9e..4760fe068 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts @@ -1,2 +1 @@ -export { createTestApplicationServicesFactory } from './test-application-services.ts'; export { createRealApplicationServicesFactory } from './real-application-services.ts'; diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts index 3f3480b2f..34d181e04 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts @@ -11,7 +11,7 @@ import type { } from '@cellix/service-token-validation'; import type { MessagingService } from '@cellix/service-messaging-base'; import type { PaymentService } from '@cellix/service-payment-base'; -import { defaultActor } from '../test-data/test-actors.ts'; +import { defaultActor } from '@sthrift-verification/shared/test-data'; function createMockTokenValidation(): TokenValidation { return { diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/account-plan.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/account-plan.test-app-services.ts deleted file mode 100644 index a6b0954a4..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/account-plan.test-app-services.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockAccountPlan, - getAllMockAccountPlans, -} from '../../test-data/account-plan.test-data.ts'; - -interface MockAccountPlanContextApplicationService { - AccountPlan: { - create: () => Promise; - queryAll: () => Promise; - queryById: () => Promise; - queryByName: () => Promise; - }; -} - -export function createMockAccountPlanService(): MockAccountPlanContextApplicationService { - return { - AccountPlan: { - create: () => Promise.resolve(createMockAccountPlan()), - queryAll: () => Promise.resolve(getAllMockAccountPlans()), - queryById: () => Promise.resolve(null), - queryByName: () => Promise.resolve(null), - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/appeal-request.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/appeal-request.test-app-services.ts deleted file mode 100644 index 74801328b..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/appeal-request.test-app-services.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockListingAppeal, - createMockUserAppeal, - getAllMockListingAppeals, - getAllMockUserAppeals, -} from '../../test-data/appeal-request.test-data.ts'; - -interface MockAppealRequestContextApplicationService { - ListingAppealRequest: { - create: () => Promise; - getById: () => Promise; - getAll: () => Promise<{ items: Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference[]; total: number; page: number; pageSize: number }>; - updateState: () => Promise; - }; - UserAppealRequest: { - create: () => Promise; - getById: () => Promise; - getAll: () => Promise<{ items: Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference[]; total: number; page: number; pageSize: number }>; - updateState: () => Promise; - }; -} - -export function createMockAppealRequestService(): MockAppealRequestContextApplicationService { - return { - ListingAppealRequest: { - create: () => Promise.resolve(createMockListingAppeal()), - getById: () => Promise.resolve(null), - getAll: () => { - const all = getAllMockListingAppeals(); - return Promise.resolve({ items: all, total: all.length, page: 1, pageSize: 10 }); - }, - updateState: () => Promise.resolve(createMockListingAppeal()), - }, - UserAppealRequest: { - create: () => Promise.resolve(createMockUserAppeal()), - getById: () => Promise.resolve(null), - getAll: () => { - const all = getAllMockUserAppeals(); - return Promise.resolve({ items: all, total: all.length, page: 1, pageSize: 10 }); - }, - updateState: () => Promise.resolve(createMockUserAppeal()), - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/conversation.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/conversation.test-app-services.ts deleted file mode 100644 index daba6e89b..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/conversation.test-app-services.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockConversation, - createMockMessage, - getAllMockConversations, -} from '../../test-data/conversation.test-data.ts'; - -interface MockConversationContextApplicationService { - Conversation: { - create: () => Promise; - queryById: () => Promise; - queryByUser: () => Promise; - sendMessage: () => Promise; - }; -} - -export function createMockConversationService(): MockConversationContextApplicationService { - return { - Conversation: { - create: () => Promise.resolve(createMockConversation()), - queryById: () => Promise.resolve(null), - queryByUser: () => Promise.resolve(getAllMockConversations()), - sendMessage: () => Promise.resolve(createMockMessage()), - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/listing.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/listing.test-app-services.ts deleted file mode 100644 index bcf47d627..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/listing.test-app-services.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockListing, - getAllMockListings, - getMockListingById, - listings, -} from '../../test-data/listing.test-data.ts'; - -interface ItemListingCreateCommand { - sharer: Domain.Contexts.User.UserEntityReference; - title: string; - description: string; - category: string; - location: string; - sharingPeriodStart: Date; - sharingPeriodEnd: Date; - images?: string[]; - isDraft?: boolean; - expiresAt?: Date; -} - -interface ItemListingQueryByIdCommand { - id: string; -} - -interface ItemListingCancelCommand { - id: string; -} - -interface ItemListingUpdateCommand { - id: string; -} - -interface ItemListingUnblockCommand { - id: string; -} - -interface MockListingContextApplicationService { - ItemListing: { - create: (command: ItemListingCreateCommand) => Promise; - queryById: (command: ItemListingQueryByIdCommand) => Promise; - queryAll: () => Promise; - queryBySharer: () => Promise; - cancel: (command: ItemListingCancelCommand) => Promise; - update: (command: ItemListingUpdateCommand) => Promise; - deleteListings: () => Promise; - unblock: (command: ItemListingUnblockCommand) => Promise; - queryPaged: () => Promise<{ items: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; total: number; page: number; pageSize: number }>; - }; -} - -export function createMockListingService(): MockListingContextApplicationService { - return { - ItemListing: { - create: (command: ItemListingCreateCommand) => { - const listing = createMockListing({ - sharer: command.sharer, - title: command.title, - description: command.description, - category: command.category, - location: command.location, - sharingPeriodStart: command.sharingPeriodStart, - sharingPeriodEnd: command.sharingPeriodEnd, - images: command.images || [], - ...(command.isDraft !== undefined && { isDraft: command.isDraft }), - }); - return Promise.resolve(listing); - }, - queryById: (command: ItemListingQueryByIdCommand) => { - return Promise.resolve(getMockListingById(command.id) || null); - }, - queryAll: () => { - return Promise.resolve(getAllMockListings()); - }, - queryBySharer: () => Promise.resolve([]), - cancel: (command: ItemListingCancelCommand) => { - const listing = getMockListingById(command.id); - if (!listing) throw new Error(`Listing not found: ${command.id}`); - return Promise.resolve(listing); - }, - update: (command: ItemListingUpdateCommand) => { - const listing = getMockListingById(command.id); - if (!listing) throw new Error(`Listing not found: ${command.id}`); - return Promise.resolve(listing); - }, - deleteListings: async () => true, - unblock: (command: ItemListingUnblockCommand) => { - const listing = getMockListingById(command.id); - if (!listing) throw new Error(`Listing not found: ${command.id}`); - return Promise.resolve(listing); - }, - queryPaged: async () => ({ - items: getAllMockListings(), - total: listings.size, - page: 1, - pageSize: 10, - }), - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/reservation-request.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/reservation-request.test-app-services.ts deleted file mode 100644 index 7d3d1e6ad..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/reservation-request.test-app-services.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockReservationRequest, - getMockActiveByListingId, - getMockReservationRequestById, -} from '../../test-data/reservation-request.test-data.ts'; - -interface ReservationRequestCreateCommand { - listingId: string; - reservationPeriodStart: Date; - reservationPeriodEnd: Date; - reserverEmail: string; -} - -interface ReservationRequestQueryByIdCommand { - id: string; -} - -interface ReservationRequestQueryActiveByListingIdCommand { - listingId: string; -} - -interface MockReservationRequestContextApplicationService { - ReservationRequest: { - create: (command: ReservationRequestCreateCommand) => Promise; - queryById: (command: ReservationRequestQueryByIdCommand) => Promise; - queryActiveByListingId: (command: ReservationRequestQueryActiveByListingIdCommand) => Promise; - queryActiveByReserverId: () => Promise; - queryPastByReserverId: () => Promise; - queryActiveByReserverIdAndListingId: () => Promise; - queryOverlapByListingIdAndReservationPeriod: () => Promise; - queryListingRequestsBySharerId: () => Promise<{ items: Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[]; total: number; page: number; pageSize: number }>; - }; -} - -export function createMockReservationRequestService(): MockReservationRequestContextApplicationService { - return { - ReservationRequest: { - create: (command: ReservationRequestCreateCommand) => { - const reservation = createMockReservationRequest({ - listingId: command.listingId, - reserverEmail: command.reserverEmail, - reservationPeriodStart: command.reservationPeriodStart, - reservationPeriodEnd: command.reservationPeriodEnd, - }); - return Promise.resolve(reservation); - }, - queryById: (command: ReservationRequestQueryByIdCommand) => { - return Promise.resolve(getMockReservationRequestById(command.id) || null); - }, - queryActiveByListingId: (command: ReservationRequestQueryActiveByListingIdCommand) => { - const results = getMockActiveByListingId(command.listingId); - return Promise.resolve(results); - }, - queryActiveByReserverId: () => Promise.resolve([]), - queryPastByReserverId: () => Promise.resolve([]), - queryActiveByReserverIdAndListingId: () => Promise.resolve(null), - queryOverlapByListingIdAndReservationPeriod: () => Promise.resolve([]), - queryListingRequestsBySharerId: () => Promise.resolve({ - items: [], - total: 0, - page: 1, - pageSize: 10, - }), - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/user.test-app-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/user.test-app-services.ts deleted file mode 100644 index dcfcfd1dc..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-app-services/user.test-app-services.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { - createMockAdminUser, - createMockUser, - users, -} from '../../test-data/user.test-data.ts'; - -interface PersonalUserQueryByIdCommand { - id: string; - fields?: string[]; -} - -interface PersonalUserQueryByEmailCommand { - email: string; -} - -interface GetAllUsersCommand { - page: number; - pageSize: number; - searchText?: string; - statusFilters?: string[]; - sorter?: { field: string; order: string }; -} - -interface UserQueryByIdCommand { - id: string; - fields?: string[]; -} - -interface GetAllAdminUsersCommand { - page: number; - pageSize: number; - searchText?: string; - statusFilters?: string[]; - sorter?: { field: string; order: string }; -} - -interface MockUserContextApplicationService { - PersonalUser: { - createIfNotExists: () => Promise; - queryById: (command: PersonalUserQueryByIdCommand) => Promise; - update: () => Promise; - queryByEmail: (command: PersonalUserQueryByEmailCommand) => Promise; - getAllUsers: (command: GetAllUsersCommand) => Promise<{ items: Domain.Contexts.User.PersonalUser.PersonalUserEntityReference[]; total: number; page: number; pageSize: number }>; - processPayment: () => Promise<{ id: string; status: string; success: boolean }>; - generatePublicKey: () => Promise; - refundPayment: () => Promise<{ id: string; status: string; success: boolean }>; - }; - AdminUser: { - createIfNotExists: () => Promise; - queryById: () => Promise; - queryByEmail: () => Promise; - queryByUsername: () => Promise; - update: () => Promise; - getAllUsers: (command: GetAllAdminUsersCommand) => Promise<{ items: Domain.Contexts.User.AdminUser.AdminUserEntityReference[]; total: number; page: number; pageSize: number }>; - blockUser: () => Promise; - unblockUser: () => Promise; - }; - User: { - queryById: (command: UserQueryByIdCommand) => Promise; - }; -} - -export function createMockUserService(): MockUserContextApplicationService { - const allUsers = Array.from(users.values()); - const alice = allUsers.find((u) => u.account.email === 'alice@example.com') as Domain.Contexts.User.PersonalUser.PersonalUserEntityReference; - - return { - PersonalUser: { - createIfNotExists: async () => alice, - queryById: (command: PersonalUserQueryByIdCommand) => { - const user = users.get(command.id); - return Promise.resolve(user?.userType === 'personal-user' ? (user as Domain.Contexts.User.PersonalUser.PersonalUserEntityReference) : null); - }, - update: async () => alice, - queryByEmail: (command: PersonalUserQueryByEmailCommand) => { - const newUser = createMockUser(command.email, command.email.split('@')[0] || 'User', 'Test'); - return Promise.resolve(newUser); - }, - getAllUsers: (command: GetAllUsersCommand) => { - const personalUsers = allUsers.filter((u) => u.userType === 'personal-user') as Domain.Contexts.User.PersonalUser.PersonalUserEntityReference[]; - return Promise.resolve({ - items: personalUsers, - total: personalUsers.length, - page: command.page, - pageSize: command.pageSize, - }); - }, - processPayment: async () => ({ - id: 'mock-txn', - status: 'SUCCEEDED', - success: true, - }), - generatePublicKey: async () => 'mock-public-key', - refundPayment: async () => ({ - id: 'mock-refund', - status: 'REFUNDED', - success: true, - }), - }, - AdminUser: { - createIfNotExists: () => Promise.resolve(createMockAdminUser()), - queryById: () => Promise.resolve(null), - queryByEmail: () => Promise.resolve(null), - queryByUsername: () => Promise.resolve(null), - update: () => Promise.resolve(createMockAdminUser()), - getAllUsers: (command: GetAllAdminUsersCommand) => { - const adminUsers = allUsers.filter((u) => u.userType === 'admin-user') as Domain.Contexts.User.AdminUser.AdminUserEntityReference[]; - return Promise.resolve({ items: adminUsers, total: adminUsers.length, page: command.page, pageSize: command.pageSize }); - }, - blockUser: () => Promise.resolve(createMockAdminUser()), - unblockUser: () => Promise.resolve(createMockAdminUser()), - }, - User: { - queryById: (command: UserQueryByIdCommand) => { - const user = users.get(command.id); - return Promise.resolve(user && user.userType === 'personal-user' ? (user as Domain.Contexts.User.PersonalUser.PersonalUserEntityReference) : null); - }, - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-application-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-application-services.ts deleted file mode 100644 index 8f59fac93..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/test-application-services.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - ApplicationServices, - ApplicationServicesFactory, - VerifiedUser, -} from '@sthrift/application-services'; -import type { Domain } from '@sthrift/domain'; -import { - users, - getVerifiedUserFromMock, -} from '../test-data/user.test-data.ts'; -import { defaultActor } from '../test-data/test-actors.ts'; - -type PersonalUserEntityReference = Domain.Contexts.User.PersonalUser.PersonalUserEntityReference; -import { createMockUserService } from './test-app-services/user.test-app-services.ts'; -import { createMockListingService } from './test-app-services/listing.test-app-services.ts'; -import { createMockReservationRequestService } from './test-app-services/reservation-request.test-app-services.ts'; -import { createMockConversationService } from './test-app-services/conversation.test-app-services.ts'; -import { createMockAccountPlanService } from './test-app-services/account-plan.test-app-services.ts'; -import { createMockAppealRequestService } from './test-app-services/appeal-request.test-app-services.ts'; - -export function createTestApplicationServicesFactory(): ApplicationServicesFactory { - const allUsers = Array.from(users.values()); - const defaultUser = allUsers.find((u) => u.account.email === defaultActor.email); - const defaultPersonalUser = defaultUser?.userType === 'personal-user' ? (defaultUser as PersonalUserEntityReference) : null; - - return { - forRequest: (): Promise => { - return Promise.resolve({ - User: createMockUserService(), - Conversation: createMockConversationService(), - AccountPlan: createMockAccountPlanService(), - AppealRequest: createMockAppealRequestService(), - ReservationRequest: createMockReservationRequestService(), - Listing: createMockListingService(), - get verifiedUser(): VerifiedUser | null { - return defaultPersonalUser ? getVerifiedUserFromMock(defaultPersonalUser) : null; - }, - } as ApplicationServices); - }, - }; -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts index 66df650bc..4a385f15b 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts @@ -1,38 +1,17 @@ -import { type Cast, type Actor, TakeNotes, Notepad } from '@serenity-js/core'; +import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; import { listingAbilities } from '../../contexts/listing/abilities/index.ts'; -import { GraphQLListingSession } from '../../contexts/listing/abilities/graphql-listing-session.ts'; -import { MongoListingSession } from '../../contexts/listing/abilities/mongo-listing-session.ts'; import { reservationRequestAbilities } from '../../contexts/reservation-request/abilities/index.ts'; -import { GraphQLReservationRequestSession } from '../../contexts/reservation-request/abilities/graphql-reservation-request-session.ts'; -import { MongoReservationRequestSession } from '../../contexts/reservation-request/abilities/mongo-reservation-request-session.ts'; -import { MultiContextSession } from '../abilities/multi-context-session.ts'; -import type { TaskLevel, SessionType } from '../../world.ts'; +import type { TaskLevel } from '../../world.ts'; +import { GraphQLClient } from '../abilities/graphql-client.ts'; export class ShareThriftCast implements Cast { constructor( private readonly tasksLevel: TaskLevel, - private readonly sessionType: SessionType, private readonly apiUrl: string, ) {} - private createMultiContextSession(): MultiContextSession { - const multiSession = new MultiContextSession(); - - if (this.sessionType === 'mongodb') { - multiSession.registerSession('listing', new MongoListingSession(this.apiUrl)); - multiSession.registerSession('reservation', new MongoReservationRequestSession(this.apiUrl)); - } else { - multiSession.registerSession('listing', new GraphQLListingSession(this.apiUrl)); - multiSession.registerSession('reservation', new GraphQLReservationRequestSession(this.apiUrl)); - } - - return multiSession; - } - prepare(actor: Actor): Actor { - if (this.tasksLevel === 'domain' || this.tasksLevel === 'ui') { - // UI tests use domain abilities for setup steps (e.g., "has created a listing") - // and store results in notes; UI rendering happens in UI-specific tasks + if (this.tasksLevel === 'ui') { return actor.whoCan( TakeNotes.using(Notepad.empty()), ...listingAbilities.create(), @@ -40,9 +19,10 @@ export class ShareThriftCast implements Cast { ); } + // api level: full stack via GraphQL → app-services → domain → persistence (MongoDB) return actor.whoCan( TakeNotes.using(Notepad.empty()), - this.createMultiContextSession(), + GraphQLClient.at(this.apiUrl), ); } } diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts index caa7b35fe..1b13d0c02 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts @@ -8,21 +8,19 @@ let lastTestConfig: string | undefined; setDefaultTimeout(120_000); -Before(async function (this: IWorld<{ session?: string; tasks?: string }>) { - const world = this as IWorld<{ session?: string; tasks?: string }> & ShareThriftWorld; +Before(async function (this: IWorld<{ tasks?: string }>) { + const world = this as IWorld<{ tasks?: string }> & ShareThriftWorld; - const sessionType = this.parameters?.session ?? 'domain'; - const testConfig = `${world.level}:${sessionType}`; + const testConfig = world.level; if (lastTestConfig !== testConfig) { lastTestConfig = testConfig; if (!isAgent) { - const levelIcon = world.level === 'session' ? '📡' : '⚡'; + const levelIcon = world.level === 'api' ? '📡' : '🖥️'; const testLevelStr = world.level.toUpperCase(); - const backendStr = String(sessionType).toUpperCase(); - console.log(`\n${levelIcon} ${testLevelStr} tests with ${backendStr} backend`); + console.log(`\n${levelIcon} ${testLevelStr} tests`); console.log(' • Listing Context'); console.log(' • Reservation Request Context\n'); } @@ -31,8 +29,8 @@ Before(async function (this: IWorld<{ session?: string; tasks?: string }>) { await world.init(); }); -After(async function (this: IWorld<{ session?: string; tasks?: string }>) { - const world = this as IWorld<{ session?: string; tasks?: string }> & ShareThriftWorld; +After(async function (this: IWorld<{ tasks?: string }>) { + const world = this as IWorld<{ tasks?: string }> & ShareThriftWorld; await world.cleanup(); }); diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts index 90455a37f..b0d52d1e4 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts @@ -1,8 +1,7 @@ import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { MongoClient, ObjectId } from 'mongodb'; import { ServiceMongoose } from '@cellix/service-mongoose'; -import { getAllMockAccountPlans } from '../test-data/account-plan.test-data.ts'; -import { getAllMockUsers } from '../test-data/user.test-data.ts'; +import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/shared/test-data'; const MONGO_BINARY_VERSION = '7.0.14'; const DEFAULT_DB_NAME = 'sharethrift-test'; diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts index 0645c3229..f74334161 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts @@ -1,7 +1,6 @@ import { GraphQLTestServer, MongoDBTestServer } from './servers/index.ts'; -import { createTestApplicationServicesFactory, createRealApplicationServicesFactory } from './application-services/index.ts'; +import { createRealApplicationServicesFactory } from './application-services/index.ts'; import { apiSettings } from './local-settings.ts'; -import type { SessionType } from '../../world.ts'; // Shared infrastructure — persists across scenarios within a single test run let mongoDBServer: MongoDBTestServer | undefined; @@ -45,21 +44,12 @@ async function ensureMongoDBServer(options?: { port?: number; dbName?: string }) return mongoDBServer; } -export async function ensureSessionServers(sessionType: SessionType): Promise { +export async function ensureApiServers(): Promise { if (graphQLServer) return; - if (sessionType === 'graphql') { - const testFactory = createTestApplicationServicesFactory(); - graphQLServer = new GraphQLTestServer(testFactory); - await graphQLServer.start(); - apiUrl = graphQLServer.getUrl(); - } - - if (sessionType === 'mongodb') { - const mongo = await ensureMongoDBServer(); - const realFactory = createRealApplicationServicesFactory(mongo.getServiceMongoose()); - graphQLServer = new GraphQLTestServer(realFactory); - await graphQLServer.start(); - apiUrl = graphQLServer.getUrl(); - } + const mongo = await ensureMongoDBServer(); + const realFactory = createRealApplicationServicesFactory(mongo.getServiceMongoose()); + graphQLServer = new GraphQLTestServer(realFactory); + await graphQLServer.start(); + apiUrl = graphQLServer.getUrl(); } diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs index 6f92980a9..a01e24343 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs @@ -7,6 +7,9 @@ const ASSET_PATTERN = /\.(css|less|scss|sass|svg|png|jpg|jpeg|gif|webp|ico|woff| // ERR_REQUIRE_CYCLE_MODULE errors when Node.js processes ESM/CJS transitions const ANTD_ES_PATTERN = /^antd\/es\//; +// Track redirected antd module URLs to apply ESM→CJS default export fix in load() +const redirectedUrls = new Set(); + export async function resolve(specifier, context, nextResolve) { if (ASSET_PATTERN.test(specifier)) { return { @@ -18,7 +21,9 @@ export async function resolve(specifier, context, nextResolve) { // Redirect antd/es/* to antd/lib/* for Node.js CJS/ESM compatibility if (ANTD_ES_PATTERN.test(specifier)) { const cjsPath = specifier.replace('antd/es/', 'antd/lib/'); - return nextResolve(cjsPath, context); + const resolved = await nextResolve(cjsPath, context); + redirectedUrls.add(resolved.url); + return resolved; } return nextResolve(specifier); @@ -32,5 +37,25 @@ export async function load(url, context, nextLoad) { shortCircuit: true, }; } + + // For antd/lib CJS modules redirected from antd/es, create ESM wrappers + // that properly unwrap the __esModule default export convention. + // Without this fix, `import Form from 'antd/es/form'` resolves to + // `{ default: FormComponent }` instead of `FormComponent` directly, + // because Node.js CJS→ESM interop wraps module.exports as-is. + if (redirectedUrls.has(url)) { + const filePath = url.startsWith('file://') ? new URL(url).pathname : url; + return { + format: 'module', + source: [ + `import { createRequire } from 'node:module';`, + `const require = createRequire(import.meta.url);`, + `const mod = require(${JSON.stringify(filePath)});`, + `export default (mod && mod.__esModule && mod.default) ? mod.default : mod;`, + ].join('\n'), + shortCircuit: true, + }; + } + return nextLoad(url, context); } diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts index aa6d4e8e1..9efb82285 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts @@ -113,8 +113,7 @@ export function ensureJsdom(): void { (import.meta as { env: Record }).env = {}; } - // Suppress React error boundary warnings in console during acceptance tests - // These are expected since antd components may partially fail to render in jsdom + // Suppress noisy console output from antd/React during acceptance tests const originalConsoleError = console.error; console.error = (...args: unknown[]) => { const message = String(args[0] ?? ''); @@ -122,13 +121,33 @@ export function ensureJsdom(): void { message.includes('Consider adding an error boundary') || message.includes('An error occurred in the <') || message.includes('Error: Uncaught') || - message.includes('Warning: Can not find FormContext') + message.includes('Warning: Can not find FormContext') || + message.includes('Encountered two children with the same key') || + message.includes('act()') ) { return; // Suppress expected rendering warnings } originalConsoleError.apply(console, args); }; + const originalConsoleLog = console.log; + console.log = (...args: unknown[]) => { + const message = String(args[0] ?? ''); + if (message.includes('Validation failed:')) { + return; // Suppress antd form validation output + } + originalConsoleLog.apply(console, args); + }; + + const originalConsoleWarn = console.warn; + console.warn = (...args: unknown[]) => { + const message = String(args[0] ?? ''); + if (message.includes('Encountered two children with the same key')) { + return; + } + originalConsoleWarn.apply(console, args); + }; + initialized = true; } diff --git a/packages/sthrift-verification/acceptance-tests/src/world.ts b/packages/sthrift-verification/acceptance-tests/src/world.ts index 7fd40c1c4..8c6259ecd 100644 --- a/packages/sthrift-verification/acceptance-tests/src/world.ts +++ b/packages/sthrift-verification/acceptance-tests/src/world.ts @@ -2,16 +2,13 @@ import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumb import { engage } from '@serenity-js/core'; import './shared/support/hooks.ts'; import { ShareThriftCast } from './shared/support/cast.ts'; -import { clearMockListings } from './shared/support/test-data/listing.test-data.ts'; -import { clearMockReservationRequests } from './shared/support/test-data/reservation-request.test-data.ts'; +import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/shared/test-data'; import * as infra from './shared/support/shared-infrastructure.ts'; -export type TaskLevel = 'domain' | 'session' | 'ui'; -export type SessionType = 'graphql' | 'mongodb'; +export type TaskLevel = 'api' | 'ui'; export interface WorldParameters { tasks: TaskLevel; - session?: SessionType; } export async function stopSharedServers(): Promise { @@ -20,24 +17,17 @@ export async function stopSharedServers(): Promise { export class ShareThriftWorld extends World { private readonly tasksLevel: TaskLevel; - private readonly sessionType: SessionType; private apiUrl: string; constructor(options: IWorldOptions) { super(options); - this.tasksLevel = options.parameters?.tasks || 'domain'; - this.sessionType = options.parameters?.session || 'graphql'; + this.tasksLevel = options.parameters?.tasks || 'api'; this.apiUrl = ''; } async init(): Promise { - if (this.tasksLevel === 'session') { - await infra.ensureSessionServers(this.sessionType); - } - - if (this.tasksLevel === 'ui') { - // jsdom setup is done lazily in UI tasks via dynamic imports - // No server infrastructure needed for UI tests + if (this.tasksLevel === 'api') { + await infra.ensureApiServers(); } const { apiUrl } = infra.getState(); @@ -49,15 +39,11 @@ export class ShareThriftWorld extends World { clearMockReservationRequests(); clearMockListings(); - engage(new ShareThriftCast( - this.tasksLevel, - this.sessionType, - this.apiUrl, - )); + engage(new ShareThriftCast(this.tasksLevel, this.apiUrl)); } async cleanup(): Promise { - // No cleanup needed for domain/session tests + // No cleanup needed } get level(): TaskLevel { diff --git a/packages/sthrift-verification/e2e-tests/package.json b/packages/sthrift-verification/e2e-tests/package.json index 21c7a4e40..d81a2f094 100644 --- a/packages/sthrift-verification/e2e-tests/package.json +++ b/packages/sthrift-verification/e2e-tests/package.json @@ -25,6 +25,7 @@ "@playwright/test": "^1.52.0", "@sthrift/application-services": "workspace:*", "@sthrift/domain": "workspace:*", + "@sthrift-verification/shared": "workspace:*", "@types/node": "^24.6.1", "mongodb": "^6.15.0", "mongodb-memory-server": "^10.2.0", diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts index 90455a37f..b0d52d1e4 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts @@ -1,8 +1,7 @@ import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { MongoClient, ObjectId } from 'mongodb'; import { ServiceMongoose } from '@cellix/service-mongoose'; -import { getAllMockAccountPlans } from '../test-data/account-plan.test-data.ts'; -import { getAllMockUsers } from '../test-data/user.test-data.ts'; +import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/shared/test-data'; const MONGO_BINARY_VERSION = '7.0.14'; const DEFAULT_DB_NAME = 'sharethrift-test'; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index 21e6aab01..2023a3a97 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -1,7 +1,7 @@ import { chromium, type Browser, type BrowserContext } from '@playwright/test'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; import { MongoDBTestServer, TestOAuth2Server, TestViteServer, TestApiServer, initTestEnvironment, cleanupTestEnvironment, setMongoConnectionString } from './servers/index.ts'; -import { defaultActor } from './test-data/test-actors.ts'; +import { defaultActor } from '@sthrift-verification/shared/test-data'; import { performOAuth2Login } from './oauth2-login.ts'; import { apiSettings } from './local-settings.ts'; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/account-plan.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/account-plan.test-data.ts deleted file mode 100644 index fed70d2e4..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/account-plan.test-data.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Domain } from '@sthrift/domain'; - -let accountPlanCounter = 1; - -const accountPlans = new Map([ - [ - '607f1f77bcf86cd799439001', - { - id: '607f1f77bcf86cd799439001', - name: 'non-verified-personal', - description: 'Non-Verified Personal', - billingPeriodLength: 0, - billingPeriodUnit: 'month', - billingAmount: 0, - currency: 'USD', - setupFee: 0, - feature: { - activeReservations: 0, - bookmarks: 3, - itemsToShare: 15, - friends: 5, - }, - schemaVersion: '1.0.0', - createdAt: new Date('2023-05-02T10:00:00Z'), - updatedAt: new Date('2023-05-02T10:00:00Z'), - } as Domain.Contexts.AccountPlan.AccountPlan.AccountPlanEntityReference, - ], - [ - '607f1f77bcf86cd799439002', - { - id: '607f1f77bcf86cd799439002', - name: 'verified-personal', - description: 'Verified Personal', - billingPeriodLength: 0, - billingPeriodUnit: 'month', - billingAmount: 0, - currency: 'USD', - setupFee: 0, - feature: { - activeReservations: 10, - bookmarks: 10, - itemsToShare: 30, - friends: 10, - }, - schemaVersion: '1.0.0', - createdAt: new Date('2023-05-02T10:00:00Z'), - updatedAt: new Date('2023-05-02T10:00:00Z'), - } as Domain.Contexts.AccountPlan.AccountPlan.AccountPlanEntityReference, - ], -]); - -export function createMockAccountPlan(): Domain.Contexts.AccountPlan.AccountPlan.AccountPlanEntityReference { - const plan = { - id: `plan-${accountPlanCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.AccountPlan.AccountPlan.AccountPlanEntityReference; - accountPlans.set(plan.id, plan); - accountPlanCounter++; - return plan; -} - -export function getAllMockAccountPlans(): Domain.Contexts.AccountPlan.AccountPlan.AccountPlanEntityReference[] { - return Array.from(accountPlans.values()); -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/appeal-request.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/appeal-request.test-data.ts deleted file mode 100644 index 2c7c52d37..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/appeal-request.test-data.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Domain } from '@sthrift/domain'; - -const listingAppeals = new Map(); -const userAppeals = new Map(); - -let listingAppealCounter = 1; -let userAppealCounter = 1; - -export function createMockListingAppeal(): Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference { - const appeal = { - id: `listing-appeal-${listingAppealCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference; - listingAppeals.set(appeal.id, appeal); - listingAppealCounter++; - return appeal; -} - -export function createMockUserAppeal(): Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference { - const appeal = { - id: `user-appeal-${userAppealCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference; - userAppeals.set(appeal.id, appeal); - userAppealCounter++; - return appeal; -} - -export function getAllMockListingAppeals(): Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference[] { - return Array.from(listingAppeals.values()); -} - -export function getAllMockUserAppeals(): Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference[] { - return Array.from(userAppeals.values()); -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/conversation.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/conversation.test-data.ts deleted file mode 100644 index 4773abc7e..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/conversation.test-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Domain } from '@sthrift/domain'; - -const conversations = new Map(); -const messages = new Map(); - -let conversationCounter = 1; -let messageCounter = 1; - -export function createMockConversation(): Domain.Contexts.Conversation.Conversation.ConversationEntityReference { - const conversation = { - id: `conversation-${conversationCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.Conversation.Conversation.ConversationEntityReference; - conversations.set(conversation.id, conversation); - conversationCounter++; - return conversation; -} - -export function createMockMessage(): Domain.Contexts.Conversation.Conversation.MessageEntityReference { - const message = { - id: `message-${messageCounter}`, - createdAt: new Date(), - } as Domain.Contexts.Conversation.Conversation.MessageEntityReference; - messages.set(message.id, message); - messageCounter++; - return message; -} - -export function getAllMockConversations(): Domain.Contexts.Conversation.Conversation.ConversationEntityReference[] { - return Array.from(conversations.values()); -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/listing.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/listing.test-data.ts deleted file mode 100644 index ec426e14b..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/listing.test-data.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Domain } from '@sthrift/domain'; -import { generateObjectId } from './utils.ts'; - -type ItemListingEntityReference = Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; - -export const listings = new Map(); - -interface CreateListingInput { - sharer: Domain.Contexts.User.UserEntityReference; - title: string; - description: string; - category: string; - location: string; - sharingPeriodStart: Date; - sharingPeriodEnd: Date; - images?: string[]; - isDraft?: boolean; - state?: string; -} - -export function createMockListing(input: CreateListingInput): ItemListingEntityReference { - const { Title, Description, Category, Location } = - Domain.Contexts.Listing.ItemListing.ItemListingValueObjects; - - // Validate using real domain value objects - const title = new Title(input.title).valueOf(); - const description = new Description(input.description).valueOf(); - const category = new Category(input.category).valueOf(); - const location = new Location(input.location).valueOf(); - - const id = generateObjectId(); - const state = input.state || (input.isDraft ? 'Draft' : 'Active'); - - const listing: ItemListingEntityReference = { - id, - sharer: input.sharer, - title, - description, - category, - location, - sharingPeriodStart: input.sharingPeriodStart, - sharingPeriodEnd: input.sharingPeriodEnd, - state, - images: input.images ?? [], - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - listingType: 'item-sharing', - isBlocked: false, - hasReports: false, - loadSharer: async () => input.sharer, - loadListing: async () => null as never, - loadReserver: async () => null as never, - } as ItemListingEntityReference; - - listings.set(id, listing); - return listing; -} - -export function getMockListingById(id: string): ItemListingEntityReference | null { - return listings.get(id) ?? null; -} - -export function getAllMockListings(): ItemListingEntityReference[] { - return Array.from(listings.values()); -} - -export function clearMockListings(): void { - listings.clear(); -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/reservation-request.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/reservation-request.test-data.ts deleted file mode 100644 index 79a626fec..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/reservation-request.test-data.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import { generateObjectId } from './utils.ts'; -import { createMockUser } from './user.test-data.ts'; -import { getMockListingById } from './listing.test-data.ts'; - -type ReservationRequestEntityReference = Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference; - -export const reservationRequests = new Map(); - -interface CreateReservationRequestInput { - listingId: string; - reserverEmail: string; - reservationPeriodStart: Date; - reservationPeriodEnd: Date; -} - -export function createMockReservationRequest(input: CreateReservationRequestInput): ReservationRequestEntityReference { - const id = generateObjectId(); - const firstName = input.reserverEmail.split('@')[0] || 'Reserver'; - const reserverUser = createMockUser(input.reserverEmail, firstName, 'Reserver'); - const listing = getMockListingById(input.listingId); - - if (!listing) { - throw new Error(`Listing not found: ${input.listingId}`); - } - - // Check for overlapping active reservations - const overlapping = Array.from(reservationRequests.values()).filter( - (r) => - r.listing.id === input.listingId && - ['Requested', 'Accepted'].includes(r.state) && - input.reservationPeriodStart < r.reservationPeriodEnd && - input.reservationPeriodEnd > r.reservationPeriodStart, - ); - - if (overlapping.length > 0) { - throw new Error('Reservation period overlaps with existing active reservation requests'); - } - - const reservation: ReservationRequestEntityReference = { - id, - state: 'Requested', - reservationPeriodStart: input.reservationPeriodStart, - reservationPeriodEnd: input.reservationPeriodEnd, - listing, - reserver: reserverUser, - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - closeRequestedBySharer: false, - closeRequestedByReserver: false, - loadListing: async () => listing, - loadReserver: async () => reserverUser, - loadSharer: async () => null as never, - } as ReservationRequestEntityReference; - - reservationRequests.set(id, reservation); - return reservation; -} - -export function getMockReservationRequestById(id: string): ReservationRequestEntityReference | null { - return reservationRequests.get(id) ?? null; -} - -export function getMockActiveByListingId(listingId: string): ReservationRequestEntityReference[] { - return Array.from(reservationRequests.values()).filter((r) => r.listing.id === listingId); -} - -export function clearMockReservationRequests(): void { - reservationRequests.clear(); -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/test-actors.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/test-actors.ts deleted file mode 100644 index 77a3aadff..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/test-actors.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Pre-defined test actors for acceptance tests -export interface TestActor { - name: string; - email: string; - givenName: string; - familyName: string; -} - -const alice: TestActor = { name: 'Alice', email: 'alice@example.com', givenName: 'Alice', familyName: 'Smith' }; -const bob: TestActor = { name: 'Bob', email: 'bob@example.com', givenName: 'Bob', familyName: 'Jones' }; -const admin: TestActor = { name: 'Admin', email: 'admin@test.com', givenName: 'Admin', familyName: 'User' }; - -export const actors = { Alice: alice, Bob: bob, Admin: admin } as const; - -export function getActor(name: string): TestActor { - const actor = actors[name as keyof typeof actors]; - if (!actor) { - throw new Error(`Unknown test actor "${name}". Known actors: ${Object.keys(actors).join(', ')}`); - } - return actor; -} - -export const defaultActor: TestActor = alice; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/user.test-data.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/user.test-data.ts deleted file mode 100644 index 4460db09b..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/user.test-data.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { Domain } from '@sthrift/domain'; -import type { VerifiedUser } from '@sthrift/application-services'; -import { generateObjectId } from './utils.ts'; - -type PersonalUserEntityReference = Domain.Contexts.User.PersonalUser.PersonalUserEntityReference; -type AdminUserEntityReference = Domain.Contexts.User.AdminUser.AdminUserEntityReference; -type AdminRoleEntityReference = Domain.Contexts.User.Role.AdminRole.AdminRoleEntityReference; -type UserEntityReference = PersonalUserEntityReference | AdminUserEntityReference; - -function createMockAdminRole(overrides?: Partial<{ id: string; roleName: string }>): AdminRoleEntityReference { - return { - id: overrides?.id ?? generateObjectId(), - roleName: overrides?.roleName ?? 'Admin', - isDefault: true, - roleType: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - get permissions() { - return { - userPermissions: { - canBlockUsers: false, - canViewAllUsers: false, - canEditUsers: false, - canDeleteUsers: false, - canManageUserRoles: false, - canAccessAnalytics: false, - canManageRoles: false, - canViewReports: false, - canDeleteContent: false, - }, - conversationPermissions: { - canViewAllConversations: false, - canEditConversations: false, - canDeleteConversations: false, - canCloseConversations: false, - canModerateConversations: false, - }, - listingPermissions: { - canViewAllListings: false, - canManageAllListings: false, - canEditListings: false, - canDeleteListings: false, - canApproveListings: false, - canRejectListings: false, - canBlockListings: false, - canUnblockListings: false, - canModerateListings: false, - }, - reservationRequestPermissions: { - canViewAllReservations: false, - canApproveReservations: false, - canRejectReservations: false, - canCancelReservations: false, - canEditReservations: false, - canModerateReservations: false, - }, - }; - }, - } as AdminRoleEntityReference; -} - -const aliceId = generateObjectId(); -const bobId = generateObjectId(); -const adminId = generateObjectId(); - -export const users = new Map([ - [ - aliceId, - { - id: aliceId, - userType: 'personal-user', - isBlocked: false, - hasCompletedOnboarding: true, - account: { - accountType: 'email', - email: 'alice@example.com', - username: 'alice', - profile: { - firstName: 'Alice', - lastName: 'Smith', - aboutMe: '', - location: { - address1: '', - address2: null, - city: '', - state: '', - country: '', - zipCode: '', - }, - billing: {}, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - } as PersonalUserEntityReference, - ], - [ - bobId, - { - id: bobId, - userType: 'personal-user', - isBlocked: false, - hasCompletedOnboarding: true, - account: { - accountType: 'email', - email: 'bob@example.com', - username: 'bob', - profile: { - firstName: 'Bob', - lastName: 'Jones', - aboutMe: '', - location: { - address1: '', - address2: null, - city: '', - state: '', - country: '', - zipCode: '', - }, - billing: {}, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - } as PersonalUserEntityReference, - ], - [ - adminId, - { - id: adminId, - userType: 'admin-user', - isBlocked: false, - hasCompletedOnboarding: true, - role: createMockAdminRole(), - loadRole: async () => createMockAdminRole(), - account: { - accountType: 'admin', - email: 'admin@test.com', - username: 'admin', - profile: { - firstName: 'Admin', - lastName: 'User', - aboutMe: '', - location: { - address1: '123 Test St', - address2: null, - city: 'Seattle', - state: 'WA', - country: 'US', - zipCode: '98101', - }, - billing: { - cybersourceCustomerId: null, - subscription: { - status: 'inactive', - planCode: 'free', - startDate: new Date('2020-01-01'), - subscriptionId: null, - }, - transactions: { - items: [], - getNewItem: () => ({}), - addItem: () => { /* no-op */ }, - removeItem: () => { /* no-op */ }, - removeAll: () => { /* no-op */ }, - }, - }, - }, - }, - schemaVersion: '1.0.0', - createdAt: new Date(), - updatedAt: new Date(), - } as AdminUserEntityReference, - ], -]); - -export function createMockUser(email: string, firstName: string, lastName: string): PersonalUserEntityReference { - const id = generateObjectId(); - const user = { - id, - userType: 'personal-user', - isBlocked: false, - hasCompletedOnboarding: true, - account: { - accountType: 'email', - email, - username: email.split('@')[0], - profile: { - firstName, - lastName, - aboutMe: '', - location: { - address1: '', - address2: null, - city: '', - state: '', - country: '', - zipCode: '', - }, - billing: {}, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - schemaVersion: '1.0.0', - } as PersonalUserEntityReference; - users.set(id, user); - return user; -} - -export function createMockAdminUser(email?: string, firstName?: string, lastName?: string): AdminUserEntityReference { - const adminUser = { - id: generateObjectId(), - userType: 'admin-user', - isBlocked: false, - hasCompletedOnboarding: true, - role: createMockAdminRole(), - loadRole: async () => createMockAdminRole(), - account: { - accountType: 'admin', - email: email || 'admin@test.com', - username: (email?.split('@')[0]) || 'admin', - profile: { - firstName: firstName || 'Admin', - lastName: lastName || 'User', - aboutMe: '', - location: { - address1: '123 Test St', - address2: null, - city: 'Seattle', - state: 'WA', - country: 'US', - zipCode: '98101', - }, - billing: { - cybersourceCustomerId: null, - subscription: { - status: 'inactive', - planCode: 'free', - startDate: new Date('2020-01-01'), - subscriptionId: null, - }, - transactions: { - items: [], - getNewItem: () => ({}), - addItem: () => { /* no-op */ }, - removeItem: () => { /* no-op */ }, - removeAll: () => { /* no-op */ }, - }, - }, - }, - }, - schemaVersion: '1.0.0', - createdAt: new Date(), - updatedAt: new Date(), - } as AdminUserEntityReference; - users.set(adminUser.id, adminUser); - return adminUser; -} - -export function getAllMockUsers(): UserEntityReference[] { - return Array.from(users.values()); -} - -export function getVerifiedUserFromMock(user: PersonalUserEntityReference): VerifiedUser { - return { - verifiedJwt: { - email: user.account.email, - given_name: user.account.profile.firstName, - family_name: user.account.profile.lastName, - sub: user.id, - }, - openIdConfigKey: 'UserPortal', - hints: undefined, - }; -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/utils.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/utils.ts deleted file mode 100644 index 99b4b6b05..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/test-data/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -export function generateObjectId(): string { - const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0'); - const random = randomBytes(8).toString('hex'); - return (timestamp + random).substring(0, 24); -} diff --git a/packages/sthrift-verification/e2e-tests/src/world.ts b/packages/sthrift-verification/e2e-tests/src/world.ts index b923ffd92..6be06e20e 100644 --- a/packages/sthrift-verification/e2e-tests/src/world.ts +++ b/packages/sthrift-verification/e2e-tests/src/world.ts @@ -2,8 +2,7 @@ import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumb import { engage } from '@serenity-js/core'; import './shared/support/hooks.ts'; import { ShareThriftCast } from './shared/support/cast.ts'; -import { clearMockListings } from './shared/support/test-data/listing.test-data.ts'; -import { clearMockReservationRequests } from './shared/support/test-data/reservation-request.test-data.ts'; +import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/shared/test-data'; import * as infra from './shared/support/shared-infrastructure.ts'; export async function stopSharedServers(): Promise { diff --git a/packages/sthrift-verification/shared/package.json b/packages/sthrift-verification/shared/package.json new file mode 100644 index 000000000..f4701390c --- /dev/null +++ b/packages/sthrift-verification/shared/package.json @@ -0,0 +1,32 @@ +{ + "name": "@sthrift-verification/shared", + "version": "1.0.0", + "description": "Shared test utilities: universal page objects, test data, and helpers for acceptance and e2e tests", + "private": true, + "type": "module", + "exports": { + "./test-data": "./src/test-data/index.ts", + "./pages": "./src/pages/index.ts", + "./pages/jsdom": "./src/pages/adapters/jsdom-adapter.ts", + "./pages/playwright": "./src/pages/adapters/playwright-adapter.ts" + }, + "dependencies": { + "@sthrift/domain": "workspace:*", + "@sthrift/application-services": "workspace:*" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@types/node": "^24.6.1", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0", + "@testing-library/react": ">=16.0.0", + "jsdom": ">=24.0.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { "optional": true }, + "@testing-library/react": { "optional": true }, + "jsdom": { "optional": true } + } +} diff --git a/packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts b/packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts new file mode 100644 index 000000000..f38248714 --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts @@ -0,0 +1,127 @@ +/** + * jsdom adapter — implements PageAdapter for acceptance-test UI tests. + * Uses container.querySelector / @testing-library fireEvent under the hood. + * + * This module is loaded dynamically (after jsdom setup), so static imports are safe. + */ +import { fireEvent } from '@testing-library/react'; +import type { ElementHandle, PageAdapter } from '../page-adapter.ts'; + +class JsdomElementHandle implements ElementHandle { + constructor(private readonly el: Element | null) {} + + fill(value: string): Promise { + if (this.el) { + fireEvent.change(this.el, { target: { value } }); + } + return Promise.resolve(); + } + + click(): Promise { + if (this.el) { + fireEvent.click(this.el); + } + return Promise.resolve(); + } + + textContent(): Promise { + return Promise.resolve(this.el?.textContent ?? null); + } + + getAttribute(name: string): Promise { + return Promise.resolve(this.el?.getAttribute(name) ?? null); + } + + isVisible(): Promise { + return Promise.resolve(this.el !== null); + } + + querySelector(selector: string): Promise { + const child = this.el?.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), + ), + ); + } +} + +export class JsdomPageAdapter implements PageAdapter { + constructor(private readonly container: Element) {} + + getByPlaceholder(text: string): ElementHandle { + const el = this.container.querySelector( + `[placeholder="${text}"], [placeholder*="${text}"]`, + ); + return new JsdomElementHandle(el); + } + + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { + const candidates = Array.from( + this.container.querySelectorAll(`[role="${role}"], ${role}`), + ); + + // Also search for semantic elements (button, input, etc.) + const semanticMap: Record = { + button: 'button', + textbox: 'input[type="text"], input:not([type]), textarea', + combobox: 'select, [role="combobox"]', + table: 'table', + }; + 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); + } + } + + 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); + }); + return new JsdomElementHandle(match ?? null); + } + + return new JsdomElementHandle(candidates[0] ?? null); + } + + locator(selector: string): ElementHandle { + const el = this.container.querySelector(selector); + return new JsdomElementHandle(el); + } + + 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 + while ((node = walker.nextNode())) { + 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 JsdomElementHandle(null); + } +} diff --git a/packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts b/packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts new file mode 100644 index 000000000..1185d8457 --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts @@ -0,0 +1,77 @@ +/** + * Playwright adapter — implements PageAdapter for E2E tests. + * Wraps Playwright's Page/Locator API behind the universal PageAdapter interface. + */ +import type { ElementHandle, PageAdapter } from '../page-adapter.ts'; + +type PlaywrightPage = import('@playwright/test').Page; +type PlaywrightLocator = import('@playwright/test').Locator; + +class PlaywrightElementHandle implements ElementHandle { + constructor(private readonly locator: PlaywrightLocator) {} + + async fill(value: string): Promise { + await this.locator.fill(value); + } + + async click(): Promise { + await this.locator.click(); + } + + textContent(): Promise { + return this.locator.textContent(); + } + + getAttribute(name: string): Promise { + return this.locator.getAttribute(name); + } + + isVisible(): Promise { + return this.locator.isVisible(); + } + + querySelector(selector: string): Promise { + const child = this.locator.locator(selector).first(); + return Promise.resolve(new PlaywrightElementHandle(child)); + } + + async querySelectorAll(selector: string): Promise { + const all = this.locator.locator(selector); + const count = await all.count(); + const handles: ElementHandle[] = []; + for (let i = 0; i < count; i++) { + handles.push(new PlaywrightElementHandle(all.nth(i))); + } + return handles; + } +} + +export class PlaywrightPageAdapter implements PageAdapter { + constructor(private readonly page: PlaywrightPage) {} + + getByPlaceholder(text: string): ElementHandle { + return new PlaywrightElementHandle(this.page.getByPlaceholder(text)); + } + + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { + // Playwright's getByRole expects AriaRole type + const ariaRole = role as import('@playwright/test').AriaRole; + return new PlaywrightElementHandle( + this.page.getByRole( + ariaRole, + options ? { name: options.name } : undefined, + ), + ); + } + + locator(selector: string): ElementHandle { + return new PlaywrightElementHandle(this.page.locator(selector)); + } + + getByText( + text: string | RegExp, + _options?: { selector?: string }, + ): ElementHandle { + return new PlaywrightElementHandle(this.page.getByText(text)); + } +} diff --git a/packages/sthrift-verification/shared/src/pages/index.ts b/packages/sthrift-verification/shared/src/pages/index.ts new file mode 100644 index 000000000..c54c29737 --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/index.ts @@ -0,0 +1,7 @@ +export { ListingPage } from './listing.page.ts'; +export type { + ElementHandle, + PageAdapter, + PageAdapterMode, +} from './page-adapter.ts'; +export { formatDate, ReservationPage } from './reservation.page.ts'; diff --git a/packages/sthrift-verification/shared/src/pages/listing.page.ts b/packages/sthrift-verification/shared/src/pages/listing.page.ts new file mode 100644 index 000000000..2f91452f9 --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/listing.page.ts @@ -0,0 +1,111 @@ +import type { ElementHandle, PageAdapter } from './page-adapter.ts'; + +/** + * Universal ListingPage — works with both jsdom (acceptance UI tests) + * and Playwright (e2e tests) via the PageAdapter abstraction. + */ +export class ListingPage { + constructor(private readonly adapter: PageAdapter) {} + + // --- Create Listing form --- + get titleInput(): ElementHandle { + return this.adapter.getByPlaceholder('Enter listing title'); + } + + get descriptionInput(): ElementHandle { + return this.adapter.getByPlaceholder( + 'Describe your item and sharing terms', + ); + } + + get locationInput(): ElementHandle { + return this.adapter.getByPlaceholder('Enter location'); + } + + get categorySelect(): ElementHandle { + return this.adapter.getByRole('combobox'); + } + + categoryOption(name: string): ElementHandle { + return this.adapter.locator(`[title="${name}"]`); + } + + get imageUploadInput(): ElementHandle { + return this.adapter.locator('input[type="file"][accept="image/*"]'); + } + + get homeCreateListingButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create a Listing/i }); + } + + get saveDraftButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Save as Draft/i }); + } + + get publishButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Publish Listing/i }); + } + + get cancelButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Cancel/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + // --- Success modal --- + get modal(): ElementHandle { + return this.adapter.locator('.ant-modal'); + } + + get viewDraftButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /View Draft/i }); + } + + get viewListingButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /View Listing/i }); + } + + // --- Helper methods --- + async fillTitle(value: string): Promise { + await this.titleInput.fill(value); + } + + async fillDescription(value: string): Promise { + await this.descriptionInput.fill(value); + } + + async fillLocation(value: string): Promise { + await this.locationInput.fill(value); + } + + async selectCategory(name: string): Promise { + await this.categorySelect.click(); + await this.categoryOption(name).click(); + } + + async fillForm(data: { + title?: string; + description?: string; + category?: string; + location?: string; + }): Promise { + if (data.title) await this.fillTitle(data.title); + if (data.description) await this.fillDescription(data.description); + if (data.location) await this.fillLocation(data.location); + if (data.category) await this.selectCategory(data.category); + } + + async clickSaveDraft(): Promise { + await this.saveDraftButton.click(); + } + + async clickPublish(): Promise { + await this.publishButton.click(); + } +} diff --git a/packages/sthrift-verification/shared/src/pages/page-adapter.ts b/packages/sthrift-verification/shared/src/pages/page-adapter.ts new file mode 100644 index 000000000..8443a9e2f --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/page-adapter.ts @@ -0,0 +1,40 @@ +/** + * 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 { + /** Fire a change event (for inputs/textareas). */ + fill(value: string): Promise; + /** Click the element. */ + click(): Promise; + /** Get the text content. */ + textContent(): Promise; + /** Get an attribute value. */ + getAttribute(name: string): Promise; + /** Check whether the element exists / is visible. */ + isVisible(): Promise; + /** Query a single descendant by CSS selector. */ + querySelector(selector: string): Promise; + /** Query all descendants by CSS selector. */ + querySelectorAll(selector: string): Promise; +} + +/** + * 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 { + /** Find by placeholder text (inputs/textareas). */ + getByPlaceholder(text: string): ElementHandle; + /** Find by accessible role and optional name. */ + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; + /** Find by CSS selector. */ + locator(selector: string): ElementHandle; + /** Find by text content within a given selector scope. */ + getByText( + text: string | RegExp, + options?: { selector?: string }, + ): ElementHandle; +} + +export type PageAdapterMode = 'jsdom' | 'playwright'; diff --git a/packages/sthrift-verification/shared/src/pages/reservation.page.ts b/packages/sthrift-verification/shared/src/pages/reservation.page.ts new file mode 100644 index 000000000..1850e573c --- /dev/null +++ b/packages/sthrift-verification/shared/src/pages/reservation.page.ts @@ -0,0 +1,53 @@ +import type { ElementHandle, PageAdapter } from './page-adapter.ts'; + +/** + * Universal ReservationPage — works with both jsdom (acceptance UI tests) + * and Playwright (e2e tests) via the PageAdapter abstraction. + */ +export class ReservationPage { + constructor(private readonly adapter: PageAdapter) {} + + get rangePicker(): ElementHandle { + return this.adapter.locator('.ant-picker-range'); + } + + get reserveButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Reserve/i }); + } + + get cancelRequestButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Cancel Request/i }); + } + + get loadingIcon(): ElementHandle { + return this.adapter.locator('.anticon-loading'); + } + + get overlapErrorMessage(): ElementHandle { + return this.adapter.getByText(/overlaps with existing reservations/i); + } + + get nextMonthButton(): ElementHandle { + return this.adapter.locator('.ant-picker-header-next-btn'); + } + + calendarCell(dateStr: string): ElementHandle { + return this.adapter.locator(`td[title="${dateStr}"]`); + } + + async clickReserve(): Promise { + await this.reserveButton.click(); + } + + async clickCancelRequest(): Promise { + await this.cancelRequestButton.click(); + } + + async openDatePicker(): Promise { + await this.rangePicker.click(); + } +} + +export function formatDate(date: Date): string { + return date.toISOString().split('T')[0] ?? ''; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/account-plan.test-data.ts b/packages/sthrift-verification/shared/src/test-data/account-plan.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/account-plan.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/account-plan.test-data.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/appeal-request.test-data.ts b/packages/sthrift-verification/shared/src/test-data/appeal-request.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/appeal-request.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/appeal-request.test-data.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/conversation.test-data.ts b/packages/sthrift-verification/shared/src/test-data/conversation.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/conversation.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/conversation.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/index.ts b/packages/sthrift-verification/shared/src/test-data/index.ts new file mode 100644 index 000000000..463c4d848 --- /dev/null +++ b/packages/sthrift-verification/shared/src/test-data/index.ts @@ -0,0 +1,25 @@ +export { actors, getActor, defaultActor, type TestActor } from './test-actors.ts'; +export { generateObjectId } from './utils.ts'; +export { + listings, + createMockListing, + getMockListingById, + getAllMockListings, + clearMockListings, +} from './listing.test-data.ts'; +export { + reservationRequests, + createMockReservationRequest, + getMockReservationRequestById, + getMockActiveByListingId, + clearMockReservationRequests, +} from './reservation-request.test-data.ts'; +export { + createMockUser, + createMockAdminUser, + getAllMockUsers, + getVerifiedUserFromMock, +} from './user.test-data.ts'; +export { + getAllMockAccountPlans, +} from './account-plan.test-data.ts'; diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/listing.test-data.ts b/packages/sthrift-verification/shared/src/test-data/listing.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/listing.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/listing.test-data.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/reservation-request.test-data.ts b/packages/sthrift-verification/shared/src/test-data/reservation-request.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/reservation-request.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/reservation-request.test-data.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/test-actors.ts b/packages/sthrift-verification/shared/src/test-data/test-actors.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/test-actors.ts rename to packages/sthrift-verification/shared/src/test-data/test-actors.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/user.test-data.ts b/packages/sthrift-verification/shared/src/test-data/user.test-data.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/user.test-data.ts rename to packages/sthrift-verification/shared/src/test-data/user.test-data.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/utils.ts b/packages/sthrift-verification/shared/src/test-data/utils.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/test-data/utils.ts rename to packages/sthrift-verification/shared/src/test-data/utils.ts diff --git a/packages/sthrift-verification/shared/tsconfig.json b/packages/sthrift-verification/shared/tsconfig.json new file mode 100644 index 000000000..f627c1d09 --- /dev/null +++ b/packages/sthrift-verification/shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cellix/typescript-config/node.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "erasableSyntaxOnly": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 426ca2b4f..ea3e3d0ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1188,6 +1188,9 @@ importers: '@cucumber/messages': specifier: ^32.2.0 version: 32.2.0 + '@sthrift-verification/shared': + specifier: workspace:* + version: link:../shared '@sthrift/application-services': specifier: workspace:* version: link:../../sthrift/application-services @@ -1206,6 +1209,9 @@ importers: '@sthrift/ui-components': specifier: workspace:* version: link:../../sthrift/ui-components + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/graphql-depth-limit': specifier: ^1.1.6 version: 1.1.6 @@ -1315,6 +1321,9 @@ importers: '@playwright/test': specifier: ^1.52.0 version: 1.58.2 + '@sthrift-verification/shared': + specifier: workspace:* + version: link:../shared '@sthrift/application-services': specifier: workspace:* version: link:../../sthrift/application-services @@ -1340,6 +1349,34 @@ importers: specifier: ^5.4.5 version: 5.8.3 + packages/sthrift-verification/shared: + dependencies: + '@playwright/test': + specifier: '>=1.40.0' + version: 1.58.2 + '@sthrift/application-services': + specifier: workspace:* + version: link:../../sthrift/application-services + '@sthrift/domain': + specifier: workspace:* + version: link:../../sthrift/domain + '@testing-library/react': + specifier: '>=16.0.0' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + jsdom: + specifier: '>=24.0.0' + version: 26.1.0 + devDependencies: + '@cellix/typescript-config': + specifier: workspace:* + version: link:../../cellix/typescript-config + '@types/node': + specifier: ^24.10.7 + version: 24.12.0 + typescript: + specifier: ^5.4.5 + version: 5.8.3 + packages/sthrift/application-services: dependencies: '@cellix/service-payment-base': @@ -14265,7 +14302,7 @@ snapshots: '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.0.1) '@cucumber/junit-xml-formatter': 0.9.0(@cucumber/messages@32.0.1) - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) '@cucumber/messages': 32.0.1 '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.7.0)(@cucumber/messages@32.0.1) '@cucumber/tag-expressions': 9.1.0 @@ -14302,7 +14339,7 @@ snapshots: '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1))(@cucumber/messages@32.0.1)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) '@cucumber/messages': 32.0.1 commander: 14.0.0 source-map-support: 0.5.21 @@ -14351,9 +14388,9 @@ snapshots: luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.0.1(@cucumber/messages@32.2.0)': + '@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.0.1 '@cucumber/messages@26.0.1': dependencies: From 178e5e4fb26540a7381483d20fa0b130aa69c6d7 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Fri, 3 Apr 2026 10:05:18 -0400 Subject: [PATCH 3/7] progress checkin for enforcing coverge for ui among reusability changes --- .../acceptance-tests/cucumber.js | 2 +- .../acceptance-tests/package.json | 2 +- .../abilities/create-listing-ability.ts | 2 +- .../listing/tasks/ui/create-listing.ts | 4 +- .../create-reservation-request-ability.ts | 2 +- .../tasks/ui/create-reservation-request.ts | 4 +- .../real-application-services.ts | 2 +- .../support/servers/test-mongodb-server.ts | 2 +- .../acceptance-tests/src/world.ts | 2 +- .../e2e-tests/cucumber.js | 2 +- .../e2e-tests/package.json | 2 +- .../listing/features/create-listing.feature | 48 ------------------- .../create-reservation-request.feature | 38 --------------- .../tasks/create-reservation-request.ts | 3 ++ .../support/servers/test-mongodb-server.ts | 2 +- .../shared/support/shared-infrastructure.ts | 2 +- .../e2e-tests/src/world.ts | 2 +- .../{shared => test-support}/package.json | 2 +- .../src/pages/adapters/jsdom-adapter.ts | 0 .../src/pages/adapters/playwright-adapter.ts | 7 ++- .../src/pages/index.ts | 0 .../src/pages/listing.page.ts | 0 .../src/pages/page-adapter.ts | 0 .../src/pages/reservation.page.ts | 0 .../listing}/create-listing.feature | 0 .../create-reservation-request.feature | 0 .../test-support/src/scenarios/steps/.gitkeep | 0 .../src/test-data/account-plan.test-data.ts | 0 .../src/test-data/appeal-request.test-data.ts | 0 .../src/test-data/conversation.test-data.ts | 0 .../src/test-data/index.ts | 0 .../src/test-data/listing.test-data.ts | 0 .../reservation-request.test-data.ts | 0 .../src/test-data/test-actors.ts | 0 .../src/test-data/user.test-data.ts | 0 .../src/test-data/utils.ts | 0 .../{shared => test-support}/tsconfig.json | 0 pnpm-lock.yaml | 10 ++-- 38 files changed, 28 insertions(+), 112 deletions(-) delete mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/listing/features/create-listing.feature delete mode 100644 packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/features/create-reservation-request.feature rename packages/sthrift-verification/{shared => test-support}/package.json (95%) rename packages/sthrift-verification/{shared => test-support}/src/pages/adapters/jsdom-adapter.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/pages/adapters/playwright-adapter.ts (91%) rename packages/sthrift-verification/{shared => test-support}/src/pages/index.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/pages/listing.page.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/pages/page-adapter.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/pages/reservation.page.ts (100%) rename packages/sthrift-verification/{acceptance-tests/src/contexts/listing/features => test-support/src/scenarios/feature-files/listing}/create-listing.feature (100%) rename packages/sthrift-verification/{acceptance-tests/src/contexts/reservation-request/features => test-support/src/scenarios/feature-files/reservation-request}/create-reservation-request.feature (100%) create mode 100644 packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep rename packages/sthrift-verification/{shared => test-support}/src/test-data/account-plan.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/appeal-request.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/conversation.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/index.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/listing.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/reservation-request.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/test-actors.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/user.test-data.ts (100%) rename packages/sthrift-verification/{shared => test-support}/src/test-data/utils.ts (100%) rename packages/sthrift-verification/{shared => test-support}/tsconfig.json (100%) diff --git a/packages/sthrift-verification/acceptance-tests/cucumber.js b/packages/sthrift-verification/acceptance-tests/cucumber.js index 63d9dbabd..7a015e51e 100644 --- a/packages/sthrift-verification/acceptance-tests/cucumber.js +++ b/packages/sthrift-verification/acceptance-tests/cucumber.js @@ -5,7 +5,7 @@ const terminalFormat = isAgent : 'progress-bar'; export default { - paths: ['src/contexts/**/features/**/*.feature'], + paths: ['../test-support/src/scenarios/feature-files/**/*.feature'], import: [ 'src/world.ts', 'src/step-definitions/**/*.ts', diff --git a/packages/sthrift-verification/acceptance-tests/package.json b/packages/sthrift-verification/acceptance-tests/package.json index 853e5512d..2d4e6b622 100644 --- a/packages/sthrift-verification/acceptance-tests/package.json +++ b/packages/sthrift-verification/acceptance-tests/package.json @@ -40,7 +40,7 @@ "@sthrift/graphql": "workspace:*", "@sthrift/persistence": "workspace:*", "@sthrift/ui-components": "workspace:*", - "@sthrift-verification/shared": "workspace:*", + "@sthrift-verification/test-support": "workspace:*", "@testing-library/react": "^16.3.2", "@types/graphql-depth-limit": "^1.1.6", "@types/jsdom": "^21.1.7", diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts index 57d84b19b..34f42cba6 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts @@ -1,7 +1,7 @@ import { Ability } from '@serenity-js/core'; import { Domain } from '@sthrift/domain'; import { makeItemListingProps, makeSharerUser, ONE_DAY_MS, DEFAULT_SHARING_PERIOD_DAYS } from '../../../shared/support/domain-test-helpers.ts'; -import { listings } from '@sthrift-verification/shared/test-data'; +import { listings } from '@sthrift-verification/test-support/test-data'; type Passport = Domain.Passport; type ItemListingProps = Domain.Contexts.Listing.ItemListing.ItemListingProps; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts index 6150229a0..b5af9c297 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts @@ -1,5 +1,5 @@ import { type Actor, notes, Task } from '@serenity-js/core'; -import { ListingPage } from '@sthrift-verification/shared/pages'; +import { ListingPage } from '@sthrift-verification/test-support/pages'; import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; import type { ListingDetails, @@ -64,7 +64,7 @@ export class CreateListing extends Task { const { render, cleanup, act } = await import('@testing-library/react'); const { MemoryRouter } = await import('react-router-dom'); const { JsdomPageAdapter } = await import( - '@sthrift-verification/shared/pages/jsdom' + '@sthrift-verification/test-support/pages/jsdom' ); // Render the full CreateListing page component diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts index 9a1fb5d88..52f3e17d6 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts @@ -1,7 +1,7 @@ import { Ability } from '@serenity-js/core'; import { Domain } from '@sthrift/domain'; import { makeReservationRequestProps, makeListingReference, makeSharerUser } from '../../../shared/support/domain-test-helpers.ts'; -import { reservationRequests } from '@sthrift-verification/shared/test-data'; +import { reservationRequests } from '@sthrift-verification/test-support/test-data'; type Passport = Domain.Passport; type ReservationRequestProps = Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestProps; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts index 017197173..34dbae7fa 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts @@ -1,5 +1,5 @@ import { type Actor, notes, Task } from '@serenity-js/core'; -import { ReservationPage } from '@sthrift-verification/shared/pages'; +import { ReservationPage } from '@sthrift-verification/test-support/pages'; import { CreateReservationRequestAbility } from '../../abilities/create-reservation-request-ability.ts'; import type { CreateReservationRequestInput, @@ -74,7 +74,7 @@ export class CreateReservationRequest extends Task { const { render, cleanup, act } = await import('@testing-library/react'); const { MemoryRouter } = await import('react-router-dom'); const { JsdomPageAdapter } = await import( - '@sthrift-verification/shared/pages/jsdom' + '@sthrift-verification/test-support/pages/jsdom' ); // Render the ReservationRequestForm component diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts index 34d181e04..dcf8bf4f9 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts @@ -11,7 +11,7 @@ import type { } from '@cellix/service-token-validation'; import type { MessagingService } from '@cellix/service-messaging-base'; import type { PaymentService } from '@cellix/service-payment-base'; -import { defaultActor } from '@sthrift-verification/shared/test-data'; +import { defaultActor } from '@sthrift-verification/test-support/test-data'; function createMockTokenValidation(): TokenValidation { return { diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts index b0d52d1e4..bd3ca11cf 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts @@ -1,7 +1,7 @@ import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { MongoClient, ObjectId } from 'mongodb'; import { ServiceMongoose } from '@cellix/service-mongoose'; -import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/shared/test-data'; +import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/test-support/test-data'; const MONGO_BINARY_VERSION = '7.0.14'; const DEFAULT_DB_NAME = 'sharethrift-test'; diff --git a/packages/sthrift-verification/acceptance-tests/src/world.ts b/packages/sthrift-verification/acceptance-tests/src/world.ts index 8c6259ecd..b2f8c01d1 100644 --- a/packages/sthrift-verification/acceptance-tests/src/world.ts +++ b/packages/sthrift-verification/acceptance-tests/src/world.ts @@ -2,7 +2,7 @@ import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumb import { engage } from '@serenity-js/core'; import './shared/support/hooks.ts'; import { ShareThriftCast } from './shared/support/cast.ts'; -import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/shared/test-data'; +import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/test-support/test-data'; import * as infra from './shared/support/shared-infrastructure.ts'; export type TaskLevel = 'api' | 'ui'; diff --git a/packages/sthrift-verification/e2e-tests/cucumber.js b/packages/sthrift-verification/e2e-tests/cucumber.js index 3b52b3a20..21e92b2dc 100644 --- a/packages/sthrift-verification/e2e-tests/cucumber.js +++ b/packages/sthrift-verification/e2e-tests/cucumber.js @@ -5,7 +5,7 @@ const terminalFormat = isAgent : 'progress-bar'; export default { - paths: ['src/contexts/**/features/**/*.feature'], + paths: ['../test-support/src/scenarios/feature-files/**/*.feature'], import: [ 'src/world.ts', 'src/contexts/**/step-definitions/**/*.steps.ts', diff --git a/packages/sthrift-verification/e2e-tests/package.json b/packages/sthrift-verification/e2e-tests/package.json index d81a2f094..23bf9f478 100644 --- a/packages/sthrift-verification/e2e-tests/package.json +++ b/packages/sthrift-verification/e2e-tests/package.json @@ -25,7 +25,7 @@ "@playwright/test": "^1.52.0", "@sthrift/application-services": "workspace:*", "@sthrift/domain": "workspace:*", - "@sthrift-verification/shared": "workspace:*", + "@sthrift-verification/test-support": "workspace:*", "@types/node": "^24.6.1", "mongodb": "^6.15.0", "mongodb-memory-server": "^10.2.0", diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/features/create-listing.feature b/packages/sthrift-verification/e2e-tests/src/contexts/listing/features/create-listing.feature deleted file mode 100644 index e7ca7fd7f..000000000 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/features/create-listing.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Create Listing - - As a ShareThrift user - I want to create listings for items I want to share - So that others can borrow them - - Background: - Given Alice is an authenticated user - - Scenario: Create a draft listing with basic details - When Alice creates a listing with: - | title | Vintage Camera | - | description | Canon AE-1 in great condition | - | category | Electronics | - | location | Seattle, WA | - Then the listing should be in draft status - And the listing title should be "Vintage Camera" - - Scenario: Create listing with all optional fields - When Alice creates a listing with: - | title | Mountain Bike | - | description | Trek 3900 with 21-speed gears | - | category | Sports & Recreation | - | location | Portland, OR | - | dailyRate | 25.00 | - | weeklyRate | 150.00 | - | deposit | 100.00 | - | tags | bike, outdoor, sports | - Then the listing should be in draft status - And the listing should have a daily rate of "$25.00" - - @validation - Scenario: Cannot create listing without required fields - When Alice attempts to create a listing with: - | description | Missing title | - | category | Home & Garden | - | location | Seattle, WA | - Then she should see a listing error for "title" - And no listing should be created - - @validation - Scenario: Title must not exceed 200 characters - When Alice attempts to create a listing with: - | title | This title is intentionally made extremely long to exceed the two hundred character maximum limit that is enforced by the domain value object validation rules and should trigger an appropriate validation error message when a user attempts to create a listing with it | - | description | Long title test | - | category | Other | - | location | Anywhere | - Then she should see a listing error "Too long" diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/features/create-reservation-request.feature b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/features/create-reservation-request.feature deleted file mode 100644 index e6036b60f..000000000 --- a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/features/create-reservation-request.feature +++ /dev/null @@ -1,38 +0,0 @@ -Feature: Create Reservation Request - - As a ShareThrift user - I want to create reservation requests for items I want to borrow - So that I can arrange a borrowing period with the item owner - - Background: - Given Alice is an authenticated user - And Bob has created a listing with: - | title | Vintage Camera | - | description | Canon AE-1 in great condition | - | category | Electronics | - | location | Seattle, WA | - | isDraft | false | - - Scenario: Create a reservation request with valid dates - When Alice creates a reservation request for Bob's listing with: - | reservationPeriodStart | +1 | - | reservationPeriodEnd | +5 | - Then the reservation request should be in requested status - And the reservation request should have a start date that is 1 day from now - And the reservation request should have an end date that is 5 days from now - - Scenario: Cannot create reservation request without required fields - When Alice attempts to create a reservation request with: - | reservationPeriodStart | +1 | - Then she should see a reservation error for "reservationPeriodEnd" - And no reservation request should be created - - Scenario: Cannot create overlapping reservation requests - Given Alice has already created a reservation request for Bob's listing with: - | reservationPeriodStart | +1 | - | reservationPeriodEnd | +5 | - When Alice attempts to create another reservation request for the same listing with: - | reservationPeriodStart | +3 | - | reservationPeriodEnd | +8 | - Then she should see a reservation error "Reservation period overlaps with existing active reservation requests" - And only one reservation request should exist for the listing diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts index 6cd09b4d3..0ad9894c8 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts @@ -20,6 +20,9 @@ export class CreateReservationRequest extends Task { await page.goto(`/listing/${this.input.listingId}`); await page.waitForLoadState('networkidle'); + // Wait for all GraphQL queries to resolve (skeleton disappears) + await page.locator('.ant-skeleton').waitFor({ state: 'hidden', timeout: 15_000 }); + await reservationPage.datePicker.rangePicker.waitFor({ state: 'visible', timeout: 10_000 }); if (await reservationPage.datePicker.isDisabled) { diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts index b0d52d1e4..bd3ca11cf 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-mongodb-server.ts @@ -1,7 +1,7 @@ import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { MongoClient, ObjectId } from 'mongodb'; import { ServiceMongoose } from '@cellix/service-mongoose'; -import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/shared/test-data'; +import { getAllMockAccountPlans, getAllMockUsers } from '@sthrift-verification/test-support/test-data'; const MONGO_BINARY_VERSION = '7.0.14'; const DEFAULT_DB_NAME = 'sharethrift-test'; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index 2023a3a97..1baf73741 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -1,7 +1,7 @@ import { chromium, type Browser, type BrowserContext } from '@playwright/test'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; import { MongoDBTestServer, TestOAuth2Server, TestViteServer, TestApiServer, initTestEnvironment, cleanupTestEnvironment, setMongoConnectionString } from './servers/index.ts'; -import { defaultActor } from '@sthrift-verification/shared/test-data'; +import { defaultActor } from '@sthrift-verification/test-support/test-data'; import { performOAuth2Login } from './oauth2-login.ts'; import { apiSettings } from './local-settings.ts'; diff --git a/packages/sthrift-verification/e2e-tests/src/world.ts b/packages/sthrift-verification/e2e-tests/src/world.ts index 6be06e20e..194184fd9 100644 --- a/packages/sthrift-verification/e2e-tests/src/world.ts +++ b/packages/sthrift-verification/e2e-tests/src/world.ts @@ -2,7 +2,7 @@ import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumb import { engage } from '@serenity-js/core'; import './shared/support/hooks.ts'; import { ShareThriftCast } from './shared/support/cast.ts'; -import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/shared/test-data'; +import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/test-support/test-data'; import * as infra from './shared/support/shared-infrastructure.ts'; export async function stopSharedServers(): Promise { diff --git a/packages/sthrift-verification/shared/package.json b/packages/sthrift-verification/test-support/package.json similarity index 95% rename from packages/sthrift-verification/shared/package.json rename to packages/sthrift-verification/test-support/package.json index f4701390c..8ae12cc47 100644 --- a/packages/sthrift-verification/shared/package.json +++ b/packages/sthrift-verification/test-support/package.json @@ -1,5 +1,5 @@ { - "name": "@sthrift-verification/shared", + "name": "@sthrift-verification/test-support", "version": "1.0.0", "description": "Shared test utilities: universal page objects, test data, and helpers for acceptance and e2e tests", "private": true, diff --git a/packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts b/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts similarity index 100% rename from packages/sthrift-verification/shared/src/pages/adapters/jsdom-adapter.ts rename to packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts diff --git a/packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts b/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts similarity index 91% rename from packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts rename to packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts index 1185d8457..7dd5eb8f4 100644 --- a/packages/sthrift-verification/shared/src/pages/adapters/playwright-adapter.ts +++ b/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts @@ -54,12 +54,11 @@ export class PlaywrightPageAdapter implements PageAdapter { } getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { - // Playwright's getByRole expects AriaRole type - const ariaRole = role as import('@playwright/test').AriaRole; + const roleOptions = options?.name ? { name: options.name } : undefined; return new PlaywrightElementHandle( this.page.getByRole( - ariaRole, - options ? { name: options.name } : undefined, + role as Parameters[0], + roleOptions, ), ); } diff --git a/packages/sthrift-verification/shared/src/pages/index.ts b/packages/sthrift-verification/test-support/src/pages/index.ts similarity index 100% rename from packages/sthrift-verification/shared/src/pages/index.ts rename to packages/sthrift-verification/test-support/src/pages/index.ts diff --git a/packages/sthrift-verification/shared/src/pages/listing.page.ts b/packages/sthrift-verification/test-support/src/pages/listing.page.ts similarity index 100% rename from packages/sthrift-verification/shared/src/pages/listing.page.ts rename to packages/sthrift-verification/test-support/src/pages/listing.page.ts diff --git a/packages/sthrift-verification/shared/src/pages/page-adapter.ts b/packages/sthrift-verification/test-support/src/pages/page-adapter.ts similarity index 100% rename from packages/sthrift-verification/shared/src/pages/page-adapter.ts rename to packages/sthrift-verification/test-support/src/pages/page-adapter.ts diff --git a/packages/sthrift-verification/shared/src/pages/reservation.page.ts b/packages/sthrift-verification/test-support/src/pages/reservation.page.ts similarity index 100% rename from packages/sthrift-verification/shared/src/pages/reservation.page.ts rename to packages/sthrift-verification/test-support/src/pages/reservation.page.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/features/create-listing.feature b/packages/sthrift-verification/test-support/src/scenarios/feature-files/listing/create-listing.feature similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/features/create-listing.feature rename to packages/sthrift-verification/test-support/src/scenarios/feature-files/listing/create-listing.feature diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/features/create-reservation-request.feature b/packages/sthrift-verification/test-support/src/scenarios/feature-files/reservation-request/create-reservation-request.feature similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/features/create-reservation-request.feature rename to packages/sthrift-verification/test-support/src/scenarios/feature-files/reservation-request/create-reservation-request.feature diff --git a/packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep b/packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/sthrift-verification/shared/src/test-data/account-plan.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/account-plan.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/appeal-request.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/appeal-request.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/appeal-request.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/appeal-request.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/conversation.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/conversation.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/index.ts b/packages/sthrift-verification/test-support/src/test-data/index.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/index.ts rename to packages/sthrift-verification/test-support/src/test-data/index.ts diff --git a/packages/sthrift-verification/shared/src/test-data/listing.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/listing.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/listing.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/listing.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/reservation-request.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/reservation-request.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/reservation-request.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/reservation-request.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/test-actors.ts b/packages/sthrift-verification/test-support/src/test-data/test-actors.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/test-actors.ts rename to packages/sthrift-verification/test-support/src/test-data/test-actors.ts diff --git a/packages/sthrift-verification/shared/src/test-data/user.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/user.test-data.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/user.test-data.ts rename to packages/sthrift-verification/test-support/src/test-data/user.test-data.ts diff --git a/packages/sthrift-verification/shared/src/test-data/utils.ts b/packages/sthrift-verification/test-support/src/test-data/utils.ts similarity index 100% rename from packages/sthrift-verification/shared/src/test-data/utils.ts rename to packages/sthrift-verification/test-support/src/test-data/utils.ts diff --git a/packages/sthrift-verification/shared/tsconfig.json b/packages/sthrift-verification/test-support/tsconfig.json similarity index 100% rename from packages/sthrift-verification/shared/tsconfig.json rename to packages/sthrift-verification/test-support/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea3e3d0ff..a1089b9ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1188,9 +1188,9 @@ importers: '@cucumber/messages': specifier: ^32.2.0 version: 32.2.0 - '@sthrift-verification/shared': + '@sthrift-verification/test-support': specifier: workspace:* - version: link:../shared + version: link:../test-support '@sthrift/application-services': specifier: workspace:* version: link:../../sthrift/application-services @@ -1321,9 +1321,9 @@ importers: '@playwright/test': specifier: ^1.52.0 version: 1.58.2 - '@sthrift-verification/shared': + '@sthrift-verification/test-support': specifier: workspace:* - version: link:../shared + version: link:../test-support '@sthrift/application-services': specifier: workspace:* version: link:../../sthrift/application-services @@ -1349,7 +1349,7 @@ importers: specifier: ^5.4.5 version: 5.8.3 - packages/sthrift-verification/shared: + packages/sthrift-verification/test-support: dependencies: '@playwright/test': specifier: '>=1.40.0' From 86d47dce3ca02fa144537242b89bb869cd87b6ad Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Fri, 3 Apr 2026 13:36:27 -0400 Subject: [PATCH 4/7] continued fixes and modifications so e2e uses the adapter pattern for page selecrtion --- .../listing/tasks/ui/create-listing.ts | 33 ++-- .../tasks/ui/create-reservation-request.ts | 80 ++++---- .../src/shared/support/ui/setup-jsdom.ts | 3 + .../listing/questions/listing-status.ts | 12 +- .../listing/questions/listing-title.ts | 5 +- .../contexts/listing/tasks/create-listing.ts | 84 ++++++-- .../tasks/create-reservation-request.ts | 39 ++-- .../components/date-range-picker.component.ts | 63 ------ .../e2e-tests/src/shared/pages/index.ts | 5 - .../src/shared/pages/listing.page.ts | 78 -------- .../src/shared/pages/onboarding.page.ts | 91 --------- .../src/shared/pages/reservation.page.ts | 21 -- .../e2e-tests/src/shared/support/cast.ts | 6 +- .../e2e-tests/src/shared/support/hooks.ts | 2 - .../src/shared/support/oauth2-login.ts | 14 +- .../e2e-tests/src/world.ts | 16 +- .../src/pages/adapters/jsdom-adapter.ts | 99 +++++++++- .../src/pages/adapters/playwright-adapter.ts | 66 ++++++- .../test-support/src/pages/index.ts | 2 + .../test-support/src/pages/listing.page.ts | 29 +++ .../src}/pages/login.page.ts | 21 +- .../test-support/src/pages/onboarding.page.ts | 180 ++++++++++++++++++ .../test-support/src/pages/page-adapter.ts | 33 ++++ .../src/pages/reservation.page.ts | 40 ++++ 24 files changed, 618 insertions(+), 404 deletions(-) create mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/components/date-range-picker.component.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/index.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/listing.page.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/onboarding.page.ts delete mode 100644 packages/sthrift-verification/e2e-tests/src/shared/pages/reservation.page.ts rename packages/sthrift-verification/{e2e-tests/src/shared => test-support/src}/pages/login.page.ts (57%) create mode 100644 packages/sthrift-verification/test-support/src/pages/onboarding.page.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts index b5af9c297..083507681 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts @@ -1,10 +1,18 @@ +import '../../../../shared/support/ui/setup-jsdom.ts'; import { type Actor, notes, Task } from '@serenity-js/core'; +import { render, cleanup, act } from '@testing-library/react'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ListingForm } from '@sthrift/ui-components'; +import { CreateListing as CreateListingComponent } from '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx'; import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { JsdomPageAdapter } from '@sthrift-verification/test-support/pages/jsdom'; import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; import type { ListingDetails, ListingNotes, } from '../../abilities/listing-types.ts'; +import { cleanupJsdom } from '../../../../shared/support/ui/jsdom-setup.ts'; const noop = () => undefined; @@ -52,31 +60,15 @@ export class CreateListing extends Task { } private async interactWithUI(isDraft: boolean): Promise { - const { ensureJsdom, cleanupJsdom } = await import( - '../../../../shared/support/ui/jsdom-setup.ts' - ); - ensureJsdom(); + globalThis.React = React; try { - const React = await import('react'); - const { createElement } = React; - globalThis.React = React; - const { render, cleanup, act } = await import('@testing-library/react'); - const { MemoryRouter } = await import('react-router-dom'); - const { JsdomPageAdapter } = await import( - '@sthrift-verification/test-support/pages/jsdom' - ); - // Render the full CreateListing page component - const { CreateListing: CreateListingComponent } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx' - ); - const { container } = render( - createElement( + React.createElement( MemoryRouter, null, - createElement( + React.createElement( CreateListingComponent as React.ComponentType< Record >, @@ -124,9 +116,8 @@ export class CreateListing extends Task { }); // Also render the shared ListingForm standalone for ui-components coverage - const { ListingForm } = await import('@sthrift/ui-components'); render( - createElement( + React.createElement( ListingForm as React.ComponentType>, { categories: [ diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts index 34dbae7fa..58cb6e00f 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts +++ b/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts @@ -1,10 +1,18 @@ +import '../../../../shared/support/ui/setup-jsdom.ts'; import { type Actor, notes, Task } from '@serenity-js/core'; +import { render, cleanup, act } from '@testing-library/react'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { ReservationPage } from '@sthrift-verification/test-support/pages'; +import { JsdomPageAdapter } from '@sthrift-verification/test-support/pages/jsdom'; +import { ReservationCard } from '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx'; +import { ReservationRequestForm } from '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx'; import { CreateReservationRequestAbility } from '../../abilities/create-reservation-request-ability.ts'; import type { CreateReservationRequestInput, ReservationRequestNotes, } from '../../abilities/reservation-request-types.ts'; +import { cleanupJsdom } from '../../../../shared/support/ui/jsdom-setup.ts'; const noop = () => undefined; @@ -62,31 +70,15 @@ export class CreateReservationRequest extends Task { } private async interactWithUI(): Promise { - const { ensureJsdom, cleanupJsdom } = await import( - '../../../../shared/support/ui/jsdom-setup.ts' - ); - ensureJsdom(); + globalThis.React = React; try { - const React = await import('react'); - const { createElement } = React; - globalThis.React = React; - const { render, cleanup, act } = await import('@testing-library/react'); - const { MemoryRouter } = await import('react-router-dom'); - const { JsdomPageAdapter } = await import( - '@sthrift-verification/test-support/pages/jsdom' - ); - // Render the ReservationRequestForm component - const { ReservationRequestForm } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx' - ); - const { container } = render( - createElement( + React.createElement( MemoryRouter, null, - createElement( + React.createElement( ReservationRequestForm as React.ComponentType< Record >, @@ -123,38 +115,30 @@ export class CreateReservationRequest extends Task { }); // Render ReservationCard for broader coverage - try { - const { ReservationCard } = await import( - '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx' - ); - - render( - createElement( - MemoryRouter, - null, - createElement( - ReservationCard as React.ComponentType>, - { - reservation: { - id: this.input.listingId, - listing: { - title: 'Test Listing', - images: [], - }, - state: 'Requested', - reservationPeriodStart: - this.input.reservationPeriodStart?.toISOString(), - reservationPeriodEnd: - this.input.reservationPeriodEnd?.toISOString(), + render( + React.createElement( + MemoryRouter, + null, + React.createElement( + ReservationCard as React.ComponentType>, + { + reservation: { + id: this.input.listingId, + listing: { + title: 'Test Listing', + images: [], }, - showActions: false, + state: 'Requested', + reservationPeriodStart: + this.input.reservationPeriodStart?.toISOString(), + reservationPeriodEnd: + this.input.reservationPeriodEnd?.toISOString(), }, - ), + showActions: false, + }, ), - ); - } catch { - // ReservationCard may have additional import requirements - } + ), + ); cleanup(); } finally { diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts new file mode 100644 index 000000000..ed549081a --- /dev/null +++ b/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts @@ -0,0 +1,3 @@ +import { ensureJsdom } from './jsdom-setup.ts'; + +ensureJsdom(); diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts index 2cf5d1436..360097f50 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts @@ -1,6 +1,7 @@ -import { Question, type Actor, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; +import { Question, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '../../../shared/pages/listing.page.ts'; +import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; export class ListingStatus extends Question> { constructor() { @@ -44,8 +45,11 @@ export class ListingStatus extends Question> { try { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(page); - const statusTag = listingPage.statusTagInRow(listingTitle); + const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); + const statusTag = await listingPage.statusTagInRow(listingTitle); + if (!statusTag) { + return undefined; + } await statusTag.waitFor({ state: 'visible', timeout: 3_000 }); return (await statusTag.textContent())?.trim() || undefined; } catch { diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts index 803b4196d..df3697241 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts @@ -1,6 +1,7 @@ import { Question, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '../../../shared/pages/listing.page.ts'; +import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; export class ListingTitle extends Question> { constructor() { @@ -41,7 +42,7 @@ export class ListingTitle extends Question> { try { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(page); + const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); const titleCell = listingPage.listingTitleCell(listingTitle); await titleCell.waitFor({ state: 'visible', timeout: 3_000 }); return (await titleCell.textContent())?.trim() || undefined; diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts index cce65c4b1..2c2bb6668 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts @@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url'; import { type Actor, Task, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '../../../shared/pages/listing.page.ts'; +import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; import type { ListingDetails, ListingNotes } from '../types.ts'; const TEST_IMAGE_PATH = path.resolve( @@ -23,38 +24,48 @@ export class CreateListing extends Task { async performAs(actor: Actor): Promise { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(page); + const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); await page.goto('/create-listing', { waitUntil: 'domcontentloaded' }); await page.waitForURL('**/create-listing', { timeout: 15_000, waitUntil: 'commit' }); await this.ensureCreateListingFormReady(page, listingPage); if (this.details.title) { - await listingPage.titleInput.fill(this.details.title); + await listingPage.fillTitle(this.details.title); } if (this.details.description) { - await listingPage.descriptionInput.fill(this.details.description); + await listingPage.fillDescription(this.details.description); } if (this.details.category) { - await listingPage.categorySelect.click(); - await listingPage.categoryOption(this.details.category).click(); + await listingPage.selectCategory(this.details.category); } if (this.details.location) { - await listingPage.locationInput.fill(this.details.location); + await listingPage.fillLocation(this.details.location); } - // Fill sharing period - if (await listingPage.datePicker.rangePicker.isVisible()) { + // Fill sharing period using the Ant Design date picker + const rangePickerVisible = await page.locator('.ant-picker-range').isVisible(); + if (rangePickerVisible) { const today = new Date(); const startDate = new Date(today); startDate.setDate(today.getDate() + 1); const endDate = new Date(today); endDate.setDate(today.getDate() + 30); - await listingPage.datePicker.selectDateRange(startDate, endDate); + await page.locator('.ant-picker-range').click(); + const startStr = startDate.toISOString().split('T')[0] ?? ''; + const endStr = endDate.toISOString().split('T')[0] ?? ''; + await page.locator(`td[title="${startStr}"]`).first().click(); + // Navigate to next month if end date not visible + const endCell = page.locator(`td[title="${endStr}"]`).first(); + if (!(await endCell.isVisible({ timeout: 1_000 }).catch(() => false))) { + await page.locator('.ant-picker-header-next-btn').last().click(); + } + await endCell.waitFor({ state: 'visible', timeout: 3_000 }); + await endCell.click(); await page.keyboard.press('Escape'); } @@ -64,18 +75,17 @@ export class CreateListing extends Task { // Upload a test image when publishing (required for non-draft listings) if (!isDraft) { - await listingPage.imageUploadInput.setInputFiles(TEST_IMAGE_PATH); - // Wait for the image preview to render + await page.locator('input[type="file"][accept="image/*"]').first().setInputFiles(TEST_IMAGE_PATH); await page.locator('img[src^="data:image"]').first().waitFor({ state: 'visible', timeout: 5_000 }); } const hasMissingRequired = !this.details.title; if (hasMissingRequired) { - await listingPage.publishButton.click(); + await listingPage.clickPublish(); const validationError = await listingPage.firstValidationError - .textContent({ timeout: 3_000 }) + .textContent() .catch(() => null); if (validationError) { @@ -89,7 +99,7 @@ export class CreateListing extends Task { const submitButton = isDraft ? listingPage.saveDraftButton : listingPage.publishButton; // Intercept the GraphQL mutation response to capture listing ID and errors - const getMutationResult = await listingPage.listenForMutationResponse('createItemListing'); + const getMutationResult = await this.listenForMutationResponse(page, 'createItemListing'); await submitButton.click(); @@ -130,7 +140,6 @@ export class CreateListing extends Task { } await listingPage.modal.waitFor({ state: 'visible', timeout: 5_000 }); - await listingPage.modal.getByText(expectedModalText).waitFor({ state: 'visible', timeout: 5_000 }); const modalContent = await listingPage.modal.textContent(); if (!modalContent?.includes(expectedModalText)) { @@ -159,7 +168,12 @@ export class CreateListing extends Task { } // Read listing status from the table row - const statusTag = listingPage.statusTagInRow(this.details.title); + const statusTag = await listingPage.statusTagInRow(this.details.title); + if (!statusTag) { + throw new Error( + `Listing status not found in table for "${this.details.title}"`, + ); + } await statusTag.waitFor({ state: 'visible', timeout: 5_000 }); const domStatus = await statusTag.textContent(); @@ -170,8 +184,10 @@ export class CreateListing extends Task { } // Extract listing ID from the GraphQL mutation response - const listing = mutationResult.data?.listing as Record | undefined; - const listingId = String(listing?.id ?? 'e2e-unknown'); + const listing = mutationResult.data?.['listing'] as + | Record + | undefined; + const listingId = String(listing?.['id'] ?? 'e2e-unknown'); await actor.attemptsTo( notes().set('lastListingId', listingId), @@ -201,5 +217,35 @@ export class CreateListing extends Task { }); } + private listenForMutationResponse(page: BrowseTheWeb['page'], mutationName: string): Promise<() => { error: string | undefined; data: Record | undefined }> { + let serverError: string | undefined; + let mutationData: Record | undefined; + + const listener = async (resp: import('@playwright/test').Response) => { + if (resp.request().method() !== 'POST') return; + try { + const postData = resp.request().postData(); + if (!postData?.toLowerCase().includes(mutationName.toLowerCase())) return; + const json = await resp.json(); + const entries = Array.isArray(json) ? json : [json]; + for (const entry of entries) { + const result = entry?.data?.[mutationName]; + if (result) { + mutationData = result as Record; + if (result?.status?.success === false) { + serverError = result.status.errorMessage ?? `${mutationName} failed`; + } + } + } + } catch { /* non-JSON response */ } + }; + + page.on('response', listener); + return Promise.resolve(() => { + page.off('response', listener); + return { error: serverError, data: mutationData }; + }); + } + override toString = () => `creates listing "${this.details.title}" (e2e)`; } diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts index 0ad9894c8..484b092ec 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts @@ -1,8 +1,11 @@ import { Task, type Actor, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; +import { + ReservationPage, + formatDate, +} from '@sthrift-verification/test-support/pages'; +import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; import type { CreateReservationRequestInput, ReservationRequestNotes } from '../types.ts'; -import { ReservationPage } from '../../../shared/pages/reservation.page.ts'; -import { formatDate } from '../../../shared/pages/components/date-range-picker.component.ts'; export class CreateReservationRequest extends Task { static with(input: CreateReservationRequestInput) { @@ -15,22 +18,20 @@ export class CreateReservationRequest extends Task { async performAs(actor: Actor): Promise { const { page } = BrowseTheWeb.withActor(actor); - const reservationPage = new ReservationPage(page); + const reservationPage = new ReservationPage(new PlaywrightPageAdapter(page)); await page.goto(`/listing/${this.input.listingId}`); await page.waitForLoadState('networkidle'); // Wait for all GraphQL queries to resolve (skeleton disappears) - await page.locator('.ant-skeleton').waitFor({ state: 'hidden', timeout: 15_000 }); + await reservationPage.skeleton.waitFor({ state: 'hidden', timeout: 15_000 }); - await reservationPage.datePicker.rangePicker.waitFor({ state: 'visible', timeout: 10_000 }); + await reservationPage.rangePicker.waitFor({ state: 'visible', timeout: 10_000 }); - if (await reservationPage.datePicker.isDisabled) { + if (await reservationPage.isDisabled()) { throw new Error('Reservation period overlaps with existing active reservation requests'); } - await reservationPage.datePicker.rangePicker.click(); - const hasStart = this.input.reservationPeriodStart instanceof Date; const hasEnd = this.input.reservationPeriodEnd instanceof Date; @@ -40,23 +41,32 @@ export class CreateReservationRequest extends Task { throw new Error(`Required field missing: ${missing}`); } + await reservationPage.openDatePicker(); + const startDateStr = formatDate(this.input.reservationPeriodStart); const endDateStr = formatDate(this.input.reservationPeriodEnd); - const startCell = reservationPage.datePicker.calendarCell(startDateStr); + const startCell = reservationPage.calendarCell(startDateStr); await startCell.waitFor({ state: 'visible', timeout: 5_000 }); - if (await reservationPage.datePicker.isCalendarCellDisabled(startDateStr)) { + if (await reservationPage.isCalendarCellDisabled(startDateStr)) { await page.keyboard.press('Escape'); throw new Error('Reservation period overlaps with existing active reservation requests'); } await startCell.click(); - const endCell = reservationPage.datePicker.calendarCell(endDateStr); + let endCell = reservationPage.calendarCell(endDateStr); + try { + await endCell.waitFor({ state: 'visible', timeout: 1_000 }); + } catch { + await reservationPage.nextMonthButton.click(); + endCell = reservationPage.calendarCell(endDateStr); + } + await endCell.waitFor({ state: 'visible', timeout: 5_000 }); - if (await reservationPage.datePicker.isCalendarCellDisabled(endDateStr)) { + if (await reservationPage.isCalendarCellDisabled(endDateStr)) { await page.keyboard.press('Escape'); throw new Error('Reservation period overlaps with existing active reservation requests'); } @@ -64,7 +74,8 @@ export class CreateReservationRequest extends Task { await endCell.click(); const dateSelectionError = await reservationPage.overlapErrorMessage - .textContent({ timeout: 2_000 }).catch(() => null); + .textContent() + .catch(() => null); if (dateSelectionError) { throw new Error('Reservation period overlaps with existing active reservation requests'); } @@ -91,7 +102,7 @@ export class CreateReservationRequest extends Task { } // Verify date picker is disabled after reservation - await reservationPage.datePicker.disabledPicker.waitFor({ state: 'visible', timeout: 5_000 }); + await reservationPage.disabledPicker.waitFor({ state: 'visible', timeout: 5_000 }); await actor.attemptsTo( notes().set('lastReservationRequestId', this.input.listingId), diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/components/date-range-picker.component.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/components/date-range-picker.component.ts deleted file mode 100644 index fdbf95eb4..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/components/date-range-picker.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Page } from '@playwright/test'; - -/** - * Reusable component for Ant Design DatePicker / RangePicker interactions. - * Used by both ListingPage and ReservationPage. - */ -export class DateRangePicker { - constructor(private readonly page: Page) {} - - get rangePicker() { return this.page.locator('.ant-picker-range'); } - get nextMonthButton() { return this.page.locator('.ant-picker-header-next-btn').last(); } - - calendarCell(dateStr: string) { return this.page.locator(`td[title="${dateStr}"]`).first(); } - - get isDisabled() { - return this.rangePicker.evaluate((el) => el.classList.contains('ant-picker-disabled')); - } - - get disabledPicker() { return this.page.locator('.ant-picker-range.ant-picker-disabled'); } - - isCalendarCellDisabled(dateStr: string) { - return this.calendarCell(dateStr).evaluate( - (el) => el.classList.contains('ant-picker-cell-disabled'), - ); - } - - private async waitForCalendarCell(dateStr: string) { - const cell = this.calendarCell(dateStr); - - try { - await cell.waitFor({ state: 'visible', timeout: 3_000 }); - } catch { - throw new Error( - `Expected calendar cell for "${dateStr}" to be visible before selecting the date range.`, - ); - } - - return cell; - } - - async selectDateRange(startDate: Date, endDate: Date): Promise { - await this.rangePicker.click(); - - const startStr = formatDate(startDate); - const endStr = formatDate(endDate); - - const startCell = await this.waitForCalendarCell(startStr); - await startCell.click(); - - let endCell = this.calendarCell(endStr); - if (!(await endCell.isVisible({ timeout: 1_000 }).catch(() => false))) { - await this.nextMonthButton.click(); - endCell = await this.waitForCalendarCell(endStr); - } else { - endCell = await this.waitForCalendarCell(endStr); - } - await endCell.click(); - } -} - -export function formatDate(date: Date): string { - return date.toISOString().split('T')[0] ?? ''; -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/index.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/index.ts deleted file mode 100644 index 1c28628da..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { LoginPage } from './login.page.ts'; -export { OnboardingPage } from './onboarding.page.ts'; -export { ListingPage } from './listing.page.ts'; -export { ReservationPage } from './reservation.page.ts'; -export { DateRangePicker, formatDate } from './components/date-range-picker.component.ts'; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/listing.page.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/listing.page.ts deleted file mode 100644 index 984aafef6..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/listing.page.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Page } from '@playwright/test'; -import { DateRangePicker } from './components/date-range-picker.component.ts'; - -/** - * Page object for listing-related pages: Create Listing form and My Listings table. - */ -export class ListingPage { - readonly datePicker: DateRangePicker; - - constructor(private readonly page: Page) { - this.datePicker = new DateRangePicker(page); - } - - // --- Create Listing form --- - get titleInput() { return this.page.getByPlaceholder('Enter listing title'); } - get descriptionInput() { return this.page.getByPlaceholder('Describe your item and sharing terms'); } - get locationInput() { return this.page.getByPlaceholder('Enter location'); } - get categorySelect() { return this.page.getByRole('combobox').first(); } - categoryOption(name: string) { return this.page.getByTitle(name, { exact: true }); } - get imageUploadInput() { return this.page.locator('input[type="file"][accept="image/*"]').first(); } - get homeCreateListingButton() { return this.page.getByRole('button', { name: /Create a Listing/i }).first(); } - get saveDraftButton() { return this.page.getByRole('button', { name: /Save as Draft/i }); } - get publishButton() { return this.page.getByRole('button', { name: /Publish Listing/i }); } - get firstValidationError() { return this.page.locator('.ant-form-item-explain-error').first(); } - get errorToast() { return this.page.locator('.ant-message-error, [role="alert"]').last(); } - - // --- Success modal --- - get modal() { return this.page.locator('.ant-modal'); } - get viewDraftButton() { return this.modal.getByRole('button', { name: /View Draft/i }); } - get viewListingButton() { return this.modal.getByRole('button', { name: /View Listing/i }); } - - // --- My Listings table --- - listingRowByTitle(title: string) { - return this.page.getByRole('table').locator('tr').filter({ hasText: title }); - } - - listingTitleCell(title: string) { - return this.page.getByRole('table').locator('span').filter({ hasText: title }).first(); - } - - statusTagInRow(title: string) { - return this.listingRowByTitle(title).locator('.ant-tag').first(); - } - - // --- Loading indicator --- - get loadingButton() { return this.page.locator('.ant-btn-loading').first(); } - - // --- Helper to intercept a GraphQL mutation response --- - listenForMutationResponse(mutationName: string): Promise<() => { error?: string; data?: Record }> { - let serverError: string | undefined; - let mutationData: Record | undefined; - - const listener = async (resp: import('@playwright/test').Response) => { - if (resp.request().method() !== 'POST') return; - try { - const postData = resp.request().postData(); - if (!postData?.toLowerCase().includes(mutationName.toLowerCase())) return; - const json = await resp.json(); - const entries = Array.isArray(json) ? json : [json]; - for (const entry of entries) { - const result = entry?.data?.[mutationName]; - if (result) { - mutationData = result as Record; - if (result?.status?.success === false) { - serverError = result.status.errorMessage ?? `${mutationName} failed`; - } - } - } - } catch { /* non-JSON response */ } - }; - - this.page.on('response', listener); - return Promise.resolve(() => { - this.page.off('response', listener); - return { error: serverError, data: mutationData }; - }); - } -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/onboarding.page.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/onboarding.page.ts deleted file mode 100644 index 1ed7fd7fb..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/onboarding.page.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Page } from '@playwright/test'; - -/** - * Page object for the post-login onboarding flow. - * Covers all four signup steps: account type, account setup, profile setup, and terms. - */ -export class OnboardingPage { - constructor(private readonly page: Page) {} - - // --- Shared --- - get saveAndContinueButton() { return this.page.getByRole('button', { name: 'Save and Continue' }); } - - // --- Step 1: Select Account Type --- - async waitForSelectAccountType(): Promise { - await this.page.waitForURL('**/signup/select-account-type', { timeout: 10_000 }); - } - - // --- Step 2: Account Setup --- - get usernameInput() { return this.page.getByLabel('Username'); } - - async waitForAccountSetup(): Promise { - await this.page.waitForURL('**/signup/account-setup', { timeout: 10_000 }); - } - - // --- Step 3: Profile Setup --- - get firstNameInput() { return this.page.getByLabel('First Name'); } - get lastNameInput() { return this.page.getByLabel('Last Name'); } - get addressLine1Input() { return this.page.getByLabel('Address Line 1'); } - get cityInput() { return this.page.getByLabel('City'); } - get zipCodeInput() { return this.page.getByLabel('Zip Code'); } - get countrySelect() { return this.page.locator('.ant-form-item').filter({ hasText: 'Country' }).locator('.ant-select'); } - get stateSelect() { return this.page.locator('.ant-form-item').filter({ hasText: 'State / Province' }).locator('.ant-select'); } - - async waitForProfileSetup(): Promise { - await this.page.waitForURL('**/signup/profile-setup', { timeout: 10_000 }); - } - - async selectCountry(country: string): Promise { - await this.countrySelect.click(); - await this.page.locator('.ant-select-dropdown:visible input.ant-select-selection-search-input, .ant-select-selection-search-input').last().fill(country); - await this.page.locator(`.ant-select-item-option[title="${country}"]`).click(); - } - - async selectState(state: string): Promise { - await this.page.waitForTimeout(500); - await this.stateSelect.click(); - await this.page.locator(`.ant-select-item-option[title="${state}"]`).click(); - } - - // --- Step 4: Terms --- - get termsCheckbox() { return this.page.getByRole('checkbox'); } - - async waitForTerms(): Promise { - await this.page.waitForURL('**/signup/terms', { timeout: 10_000 }); - } - - // --- Full flow --- - async completeOnboarding(): Promise { - // Step 1: Select Account Type - await this.waitForSelectAccountType(); - await this.saveAndContinueButton.click(); - - // Step 2: Account Setup - await this.waitForAccountSetup(); - await this.usernameInput.clear(); - await this.usernameInput.fill(`testuser_${Date.now()}`); - await this.saveAndContinueButton.click(); - - // Step 3: Profile Setup - await this.waitForProfileSetup(); - await this.firstNameInput.fill('Test'); - await this.lastNameInput.fill('User'); - await this.addressLine1Input.fill('123 Test Street'); - await this.cityInput.fill('Testville'); - await this.selectCountry('United States'); - await this.selectState('California'); - await this.zipCodeInput.fill('90210'); - await this.saveAndContinueButton.click(); - - // Step 4: Terms - await this.waitForTerms(); - await this.termsCheckbox.check(); - await this.saveAndContinueButton.click(); - - // Wait for navigation to home page - await this.page.waitForURL( - (url) => !url.pathname.includes('/signup'), - { timeout: 15_000 }, - ); - } -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/reservation.page.ts b/packages/sthrift-verification/e2e-tests/src/shared/pages/reservation.page.ts deleted file mode 100644 index 68cfccf67..000000000 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/reservation.page.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Page } from '@playwright/test'; -import { DateRangePicker } from './components/date-range-picker.component.ts'; - -/** - * Page object for the reservation request flow on the listing detail page. - */ -export class ReservationPage { - readonly datePicker: DateRangePicker; - - constructor(private readonly page: Page) { - this.datePicker = new DateRangePicker(page); - } - - get overlapErrorMessage() { - return this.page.locator('div').filter({ hasText: /overlaps with existing reservations/i }).first(); - } - - get reserveButton() { return this.page.getByRole('button', { name: /Reserve/i }); } - get cancelRequestButton() { return this.page.getByRole('button', { name: /Cancel Request/i }); } - get loadingIcon() { return this.page.locator('.anticon-loading').first(); } -} diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/cast.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/cast.ts index 7ff448734..b90792437 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/cast.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/cast.ts @@ -2,11 +2,7 @@ import { type Cast, type Actor, TakeNotes, Notepad } from '@serenity-js/core'; import type { BrowseTheWeb } from '../abilities/browse-the-web.ts'; export class ShareThriftCast implements Cast { - constructor( - private readonly apiUrl: string, - private readonly browseTheWeb?: BrowseTheWeb, - private readonly authToken?: string, - ) {} + constructor(private readonly browseTheWeb?: BrowseTheWeb) {} prepare(actor: Actor): Actor { if (!this.browseTheWeb) { diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts index b14098706..8e2347300 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts @@ -1,13 +1,11 @@ import type { IWorld, ITestCaseHookParameter } from '@cucumber/cucumber'; import { After, AfterAll, Before, Status, setDefaultTimeout } from '@cucumber/cucumber'; -import { isAgent } from 'std-env'; import path from 'node:path'; import fs from 'node:fs'; import { type ShareThriftWorld, stopSharedServers } from '../../world.ts'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; - setDefaultTimeout(120_000); Before(async function (this: IWorld) { diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts index f18055d83..4ae12854d 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -1,8 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Page } from '@playwright/test'; -import { LoginPage } from '../pages/login.page.ts'; -import { OnboardingPage } from '../pages/onboarding.page.ts'; +import { + LoginPage, + OnboardingPage, +} from '@sthrift-verification/test-support/pages'; +import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; function loadTestCredentials(): { username: string; password: string } { // Load defaults from .env.test, overridable by actual environment variables @@ -30,15 +33,16 @@ function loadTestCredentials(): { username: string; password: string } { // profile, terms) before the app is ready for test scenarios. export async function performOAuth2Login(page: Page): Promise { const { username, password } = loadTestCredentials(); - const loginPage = new LoginPage(page); + const pageAdapter = new PlaywrightPageAdapter(page); + const loginPage = new LoginPage(pageAdapter); await loginPage.goto(); await loginPage.login(username, password); await loginPage.waitForRedirectComplete(); // Complete post-login onboarding if redirected to signup - if (page.url().includes('/signup')) { - const onboardingPage = new OnboardingPage(page); + if (pageAdapter.url().includes('/signup')) { + const onboardingPage = new OnboardingPage(pageAdapter); await onboardingPage.completeOnboarding(); } } diff --git a/packages/sthrift-verification/e2e-tests/src/world.ts b/packages/sthrift-verification/e2e-tests/src/world.ts index 194184fd9..39e66c77d 100644 --- a/packages/sthrift-verification/e2e-tests/src/world.ts +++ b/packages/sthrift-verification/e2e-tests/src/world.ts @@ -1,4 +1,4 @@ -import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumber'; +import { setWorldConstructor, World } from '@cucumber/cucumber'; import { engage } from '@serenity-js/core'; import './shared/support/hooks.ts'; import { ShareThriftCast } from './shared/support/cast.ts'; @@ -10,25 +10,15 @@ export async function stopSharedServers(): Promise { } export class ShareThriftWorld extends World { - private apiUrl = ''; - async init(): Promise { await infra.ensureE2EServers(); - const { apiUrl, accessToken, browseTheWeb } = infra.getState(); - - if (apiUrl) { - this.apiUrl = apiUrl; - } + const { browseTheWeb } = infra.getState(); clearMockReservationRequests(); clearMockListings(); - engage(new ShareThriftCast( - this.apiUrl, - browseTheWeb, - accessToken, - )); + engage(new ShareThriftCast(browseTheWeb)); } async cleanup(): Promise { diff --git a/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts b/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts index f38248714..142a7b1f7 100644 --- a/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts +++ b/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts @@ -5,7 +5,46 @@ * This module is loaded dynamically (after jsdom setup), so static imports are safe. */ import { fireEvent } from '@testing-library/react'; -import type { ElementHandle, PageAdapter } from '../page-adapter.ts'; +import type { + ElementHandle, + PageAdapter, + PageNavigationWaitUntil, + PageUrlMatcher, +} from '../page-adapter.ts'; + +function getGlobalDocument(container: Element): Document { + return container.ownerDocument ?? document; +} + +function findLabelControl( + container: Element, + text: string, +): Element | null { + const doc = getGlobalDocument(container); + const labels = Array.from(container.querySelectorAll('label')); + const matchingLabel = labels.find((label) => + (label.textContent ?? '').includes(text), + ); + + if (matchingLabel) { + const forId = matchingLabel.getAttribute('for'); + if (forId) { + return doc.getElementById(forId); + } + + const wrappedControl = matchingLabel.querySelector( + 'input, textarea, select, [role="textbox"], [role="combobox"], [role="checkbox"]', + ); + if (wrappedControl) { + return wrappedControl; + } + } + + const ariaMatch = container.querySelector( + `[aria-label="${text}"], [aria-label*="${text}"]`, + ); + return ariaMatch; +} class JsdomElementHandle implements ElementHandle { constructor(private readonly el: Element | null) {} @@ -24,6 +63,18 @@ class JsdomElementHandle implements ElementHandle { return Promise.resolve(); } + check(): Promise { + if (this.el instanceof HTMLInputElement) { + fireEvent.click(this.el, { target: { checked: true } }); + return Promise.resolve(); + } + + if (this.el) { + fireEvent.click(this.el); + } + return Promise.resolve(); + } + textContent(): Promise { return Promise.resolve(this.el?.textContent ?? null); } @@ -36,6 +87,11 @@ class JsdomElementHandle implements ElementHandle { return Promise.resolve(this.el !== null); } + waitFor(_options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + // No-op in jsdom — elements are immediately available after render. + return Promise.resolve(); + } + querySelector(selector: string): Promise { const child = this.el?.querySelector(selector) ?? null; return Promise.resolve(child ? new JsdomElementHandle(child) : null); @@ -61,6 +117,10 @@ export class JsdomPageAdapter implements PageAdapter { return new JsdomElementHandle(el); } + getByLabel(text: string): ElementHandle { + return new JsdomElementHandle(findLabelControl(this.container, text)); + } + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { const candidates = Array.from( this.container.querySelectorAll(`[role="${role}"], ${role}`), @@ -71,6 +131,7 @@ export class JsdomPageAdapter implements PageAdapter { button: 'button', textbox: 'input[type="text"], input:not([type]), textarea', combobox: 'select, [role="combobox"]', + checkbox: 'input[type="checkbox"], [role="checkbox"]', table: 'table', }; const semanticSelector = semanticMap[role]; @@ -104,6 +165,14 @@ export class JsdomPageAdapter implements PageAdapter { return new JsdomElementHandle(el); } + locatorAll(selector: string): Promise { + return Promise.resolve( + Array.from(this.container.querySelectorAll(selector)).map( + (el) => new JsdomElementHandle(el), + ), + ); + } + getByText( text: string | RegExp, options?: { selector?: string }, @@ -124,4 +193,32 @@ export class JsdomPageAdapter implements PageAdapter { } return new JsdomElementHandle(null); } + + async goto( + url: string, + _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise { + if (typeof window !== 'undefined') { + window.history.pushState({}, '', url); + } + } + + waitForURL( + _url: PageUrlMatcher, + _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise { + // No-op in jsdom — shared auth pages are exercised through Playwright. + return Promise.resolve(); + } + + url(): string { + if (typeof window !== 'undefined') { + return window.location.href; + } + return 'about:blank'; + } + + waitForTimeout(_timeout: number): Promise { + return Promise.resolve(); + } } diff --git a/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts b/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts index 7dd5eb8f4..abb33008f 100644 --- a/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts +++ b/packages/sthrift-verification/test-support/src/pages/adapters/playwright-adapter.ts @@ -2,7 +2,12 @@ * Playwright adapter — implements PageAdapter for E2E tests. * Wraps Playwright's Page/Locator API behind the universal PageAdapter interface. */ -import type { ElementHandle, PageAdapter } from '../page-adapter.ts'; +import type { + ElementHandle, + PageAdapter, + PageNavigationWaitUntil, + PageUrlMatcher, +} from '../page-adapter.ts'; type PlaywrightPage = import('@playwright/test').Page; type PlaywrightLocator = import('@playwright/test').Locator; @@ -18,6 +23,10 @@ class PlaywrightElementHandle implements ElementHandle { await this.locator.click(); } + async check(): Promise { + await this.locator.check(); + } + textContent(): Promise { return this.locator.textContent(); } @@ -30,9 +39,14 @@ class PlaywrightElementHandle implements ElementHandle { return this.locator.isVisible(); } - querySelector(selector: string): Promise { + async waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise { + await this.locator.waitFor(options); + } + + async querySelector(selector: string): Promise { const child = this.locator.locator(selector).first(); - return Promise.resolve(new PlaywrightElementHandle(child)); + const count = await child.count(); + return count > 0 ? new PlaywrightElementHandle(child) : null; } async querySelectorAll(selector: string): Promise { @@ -53,6 +67,10 @@ export class PlaywrightPageAdapter implements PageAdapter { return new PlaywrightElementHandle(this.page.getByPlaceholder(text)); } + getByLabel(text: string): ElementHandle { + return new PlaywrightElementHandle(this.page.getByLabel(text)); + } + getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle { const roleOptions = options?.name ? { name: options.name } : undefined; return new PlaywrightElementHandle( @@ -67,10 +85,48 @@ export class PlaywrightPageAdapter implements PageAdapter { return new PlaywrightElementHandle(this.page.locator(selector)); } + async locatorAll(selector: string): Promise { + const all = this.page.locator(selector); + const count = await all.count(); + const handles: ElementHandle[] = []; + for (let i = 0; i < count; i++) { + handles.push(new PlaywrightElementHandle(all.nth(i))); + } + return handles; + } + getByText( text: string | RegExp, - _options?: { selector?: string }, + options?: { selector?: string }, ): ElementHandle { - return new PlaywrightElementHandle(this.page.getByText(text)); + const root = options?.selector + ? this.page.locator(options.selector) + : this.page; + return new PlaywrightElementHandle(root.getByText(text).first()); + } + + async goto( + url: string, + options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise { + await this.page.goto(url, options); + } + + async waitForURL( + url: PageUrlMatcher, + options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise { + await this.page.waitForURL( + url as Parameters[0], + options, + ); + } + + url(): string { + return this.page.url(); + } + + waitForTimeout(timeout: number): Promise { + return this.page.waitForTimeout(timeout); } } diff --git a/packages/sthrift-verification/test-support/src/pages/index.ts b/packages/sthrift-verification/test-support/src/pages/index.ts index c54c29737..734cb748c 100644 --- a/packages/sthrift-verification/test-support/src/pages/index.ts +++ b/packages/sthrift-verification/test-support/src/pages/index.ts @@ -1,3 +1,5 @@ +export { LoginPage } from './login.page.ts'; +export { OnboardingPage } from './onboarding.page.ts'; export { ListingPage } from './listing.page.ts'; export type { ElementHandle, diff --git a/packages/sthrift-verification/test-support/src/pages/listing.page.ts b/packages/sthrift-verification/test-support/src/pages/listing.page.ts index 2f91452f9..d30e6ab4c 100644 --- a/packages/sthrift-verification/test-support/src/pages/listing.page.ts +++ b/packages/sthrift-verification/test-support/src/pages/listing.page.ts @@ -58,6 +58,11 @@ export class ListingPage { 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'); @@ -71,6 +76,16 @@ export class ListingPage { return this.adapter.getByRole('button', { name: /View Listing/i }); } + // --- My Listings table --- + listingTitleCell(title: string): ElementHandle { + return this.adapter.getByText(title, { selector: 'table' }); + } + + async statusTagInRow(title: string): Promise { + const row = await this.listingRowByTitle(title); + return row ? row.querySelector('.ant-tag') : null; + } + // --- Helper methods --- async fillTitle(value: string): Promise { await this.titleInput.fill(value); @@ -108,4 +123,18 @@ export class ListingPage { async clickPublish(): Promise { await this.publishButton.click(); } + + private async listingRowByTitle(title: 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(title)) { + return row; + } + } + + return null; + } } diff --git a/packages/sthrift-verification/e2e-tests/src/shared/pages/login.page.ts b/packages/sthrift-verification/test-support/src/pages/login.page.ts similarity index 57% rename from packages/sthrift-verification/e2e-tests/src/shared/pages/login.page.ts rename to packages/sthrift-verification/test-support/src/pages/login.page.ts index 48b119c4f..e7121071b 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/pages/login.page.ts +++ b/packages/sthrift-verification/test-support/src/pages/login.page.ts @@ -1,15 +1,22 @@ -import type { Page } from '@playwright/test'; +import type { PageAdapter } from './page-adapter.ts'; /** - * Page object for the login page (/login). - * Covers the OAuth2 login form with email, password, and login button. + * Shared login page object backed by the universal page adapter. */ export class LoginPage { - constructor(private readonly page: Page) {} + constructor(private readonly page: PageAdapter) {} - get emailInput() { return this.page.getByLabel('Email'); } - get passwordInput() { return this.page.getByLabel('Password'); } - get personalLoginButton() { return this.page.getByRole('button', { name: 'Personal Login' }); } + get emailInput() { + return this.page.getByLabel('Email'); + } + + get passwordInput() { + return this.page.getByLabel('Password'); + } + + get personalLoginButton() { + return this.page.getByRole('button', { name: 'Personal Login' }); + } async goto(): Promise { await this.page.goto('/login', { waitUntil: 'networkidle' }); diff --git a/packages/sthrift-verification/test-support/src/pages/onboarding.page.ts b/packages/sthrift-verification/test-support/src/pages/onboarding.page.ts new file mode 100644 index 000000000..19687d1ad --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/onboarding.page.ts @@ -0,0 +1,180 @@ +import type { ElementHandle, PageAdapter } from './page-adapter.ts'; + +/** + * Shared onboarding page object backed by the universal page adapter. + */ +export class OnboardingPage { + constructor(private readonly page: PageAdapter) {} + + get saveAndContinueButton() { + return this.page.getByRole('button', { name: 'Save and Continue' }); + } + + get usernameInput() { + return this.page.getByLabel('Username'); + } + + get firstNameInput() { + return this.page.getByLabel('First Name'); + } + + get lastNameInput() { + return this.page.getByLabel('Last Name'); + } + + get addressLine1Input() { + return this.page.getByLabel('Address Line 1'); + } + + get cityInput() { + return this.page.getByLabel('City'); + } + + get zipCodeInput() { + return this.page.getByLabel('Zip Code'); + } + + get termsCheckbox() { + return this.page.getByRole('checkbox'); + } + + async waitForSelectAccountType(): Promise { + await this.page.waitForURL('**/signup/select-account-type', { + timeout: 10_000, + }); + } + + async waitForAccountSetup(): Promise { + await this.page.waitForURL('**/signup/account-setup', { + timeout: 10_000, + }); + } + + async waitForProfileSetup(): Promise { + await this.page.waitForURL('**/signup/profile-setup', { + timeout: 10_000, + }); + } + + async waitForTerms(): Promise { + await this.page.waitForURL('**/signup/terms', { + timeout: 10_000, + }); + } + + async selectCountry(country: string): Promise { + const countrySelect = await this.getFormControl('Country', '.ant-select'); + const optionSelector = `.ant-select-item-option[title="${country}"]`; + + await countrySelect.click(); + + const option = await this.findVisibleElement(optionSelector, 1_000); + if (option) { + await option.click(); + return; + } + + const searchInput = await this.waitForVisibleElement( + '.ant-select-selection-search-input', + 5_000, + ); + await searchInput.fill(country); + + const filteredOption = await this.waitForVisibleElement(optionSelector, 5_000); + await filteredOption.click(); + } + + async selectState(state: string): Promise { + const stateSelect = await this.getFormControl( + 'State / Province', + '.ant-select', + ); + const optionSelector = `.ant-select-item-option[title="${state}"]`; + + await this.page.waitForTimeout(500); + await stateSelect.click(); + + const option = await this.waitForVisibleElement(optionSelector, 5_000); + await option.click(); + } + + async completeOnboarding(): Promise { + await this.waitForSelectAccountType(); + await this.saveAndContinueButton.click(); + + await this.waitForAccountSetup(); + await this.usernameInput.fill(''); + await this.usernameInput.fill(`testuser_${Date.now()}`); + await this.saveAndContinueButton.click(); + + await this.waitForProfileSetup(); + await this.firstNameInput.fill('Test'); + await this.lastNameInput.fill('User'); + await this.addressLine1Input.fill('123 Test Street'); + await this.cityInput.fill('Testville'); + await this.selectCountry('United States'); + await this.selectState('California'); + await this.zipCodeInput.fill('90210'); + await this.saveAndContinueButton.click(); + + await this.waitForTerms(); + await this.termsCheckbox.check(); + await this.saveAndContinueButton.click(); + + await this.page.waitForURL((url) => !url.pathname.includes('/signup'), { + timeout: 15_000, + }); + } + + private async getFormControl( + labelText: string, + controlSelector: string, + ): Promise { + const formItems = await this.page.locatorAll('.ant-form-item'); + for (const item of formItems) { + const text = await item.textContent(); + if (!text?.includes(labelText)) { + continue; + } + + const control = await item.querySelector(controlSelector); + if (control) { + return control; + } + } + + throw new Error(`Could not find form control for "${labelText}"`); + } + + private async waitForVisibleElement( + selector: string, + timeout: number, + ): Promise { + const handle = await this.findVisibleElement(selector, timeout); + if (handle) { + return handle; + } + + throw new Error(`Could not find visible element for selector "${selector}"`); + } + + private async findVisibleElement( + selector: string, + timeout: number, + ): Promise { + const deadline = Date.now() + timeout; + while (Date.now() <= deadline) { + const handles = await this.page.locatorAll(selector); + for (let index = handles.length - 1; index >= 0; index -= 1) { + const handle = handles[index]; + if (handle && (await handle.isVisible())) { + return handle; + } + } + + await this.page.waitForTimeout(100); + } + + return null; + } +} diff --git a/packages/sthrift-verification/test-support/src/pages/page-adapter.ts b/packages/sthrift-verification/test-support/src/pages/page-adapter.ts index 8443a9e2f..5a6adfa2b 100644 --- a/packages/sthrift-verification/test-support/src/pages/page-adapter.ts +++ b/packages/sthrift-verification/test-support/src/pages/page-adapter.ts @@ -7,18 +7,33 @@ export interface ElementHandle { fill(value: string): Promise; /** Click the element. */ click(): Promise; + /** Check a checkbox or radio input. */ + check(): Promise; /** Get the text content. */ textContent(): Promise; /** Get an attribute value. */ getAttribute(name: string): Promise; /** Check whether the element exists / is visible. */ isVisible(): Promise; + /** Wait for the element to reach a given state. No-op in jsdom. */ + waitFor(options?: { state?: 'visible' | 'hidden' | 'attached' | 'detached'; timeout?: number }): Promise; /** Query a single descendant by CSS selector. */ querySelector(selector: string): Promise; /** Query all descendants by CSS selector. */ 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. @@ -26,15 +41,33 @@ export interface ElementHandle { export interface PageAdapter { /** Find by placeholder text (inputs/textareas). */ getByPlaceholder(text: string): ElementHandle; + /** Find by associated label text. */ + getByLabel(text: string): ElementHandle; /** Find by accessible role and optional name. */ getByRole(role: string, options?: { name?: string | RegExp }): ElementHandle; /** Find by CSS selector. */ locator(selector: string): ElementHandle; + /** Find all matching elements by CSS selector. */ + locatorAll(selector: string): Promise; /** Find by text content within a given selector scope. */ getByText( text: string | RegExp, options?: { selector?: string }, ): ElementHandle; + /** Navigate to a new URL. */ + goto( + url: string, + options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise; + /** Wait until the page URL matches. */ + waitForURL( + url: PageUrlMatcher, + options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, + ): Promise; + /** Read the current page URL. */ + url(): string; + /** Wait for a timeout in environments that support it. */ + waitForTimeout(timeout: number): Promise; } export type PageAdapterMode = 'jsdom' | 'playwright'; diff --git a/packages/sthrift-verification/test-support/src/pages/reservation.page.ts b/packages/sthrift-verification/test-support/src/pages/reservation.page.ts index 1850e573c..1db6cede7 100644 --- a/packages/sthrift-verification/test-support/src/pages/reservation.page.ts +++ b/packages/sthrift-verification/test-support/src/pages/reservation.page.ts @@ -11,6 +11,10 @@ export class ReservationPage { return this.adapter.locator('.ant-picker-range'); } + get disabledPicker(): ElementHandle { + return this.adapter.locator('.ant-picker-range.ant-picker-disabled'); + } + get reserveButton(): ElementHandle { return this.adapter.getByRole('button', { name: /Reserve/i }); } @@ -31,10 +35,24 @@ export class ReservationPage { return this.adapter.locator('.ant-picker-header-next-btn'); } + get skeleton(): ElementHandle { + return this.adapter.locator('.ant-skeleton'); + } + calendarCell(dateStr: string): ElementHandle { return this.adapter.locator(`td[title="${dateStr}"]`); } + async isDisabled(): Promise { + const className = await this.rangePicker.getAttribute('class'); + return className?.includes('ant-picker-disabled') ?? false; + } + + async isCalendarCellDisabled(dateStr: string): Promise { + const className = await this.calendarCell(dateStr).getAttribute('class'); + return className?.includes('ant-picker-cell-disabled') ?? false; + } + async clickReserve(): Promise { await this.reserveButton.click(); } @@ -46,6 +64,28 @@ export class ReservationPage { async openDatePicker(): Promise { await this.rangePicker.click(); } + + async selectDateRange(startDate: Date, endDate: Date): Promise { + await this.openDatePicker(); + + const startStr = formatDate(startDate); + const endStr = formatDate(endDate); + + const startCell = this.calendarCell(startStr); + await startCell.waitFor({ state: 'visible', timeout: 5_000 }); + await startCell.click(); + + let endCell = this.calendarCell(endStr); + try { + await endCell.waitFor({ state: 'visible', timeout: 1_000 }); + } catch { + await this.nextMonthButton.click(); + endCell = this.calendarCell(endStr); + await endCell.waitFor({ state: 'visible', timeout: 5_000 }); + } + + await endCell.click(); + } } export function formatDate(date: Date): string { From 4ec30005f7dd804d5fb73e2c0b8d3df78a7c96fa Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Fri, 3 Apr 2026 15:11:33 -0400 Subject: [PATCH 5/7] finished api/ui splitting and cleanup of unused code --- CLAUDE.md | 54 ++- knip.json | 9 +- package.json | 11 +- .../.c8rc.json | 6 +- .../.gitignore | 0 .../acceptance-api/README.md | 24 + .../cucumber.js | 7 +- .../acceptance-api/package.json | 48 ++ .../listing/abilities/listing-types.ts | 0 .../listing/questions/listing-status.ts | 18 - .../listing/questions/listing-title.ts | 18 - .../step-definitions/create-listing.steps.ts | 18 +- .../listing/step-definitions/index.ts | 0 .../listing/tasks/api/create-listing.ts | 0 .../abilities/reservation-request-types.ts | 0 ...t-reservation-request-count-for-listing.ts | 0 .../create-reservation-request.steps.ts | 442 ++++++++++++++++++ .../step-definitions/index.ts | 0 .../tasks/api/create-reservation-request.ts | 0 .../src/shared/abilities/graphql-client.ts | 0 .../support/application-services/index.ts | 0 .../real-application-services.ts | 0 .../acceptance-api/src/shared/support/cast.ts | 13 + .../src/shared/support/domain-test-helpers.ts | 0 .../support/formatters/agent-formatter.ts | 0 .../src/shared/support/hooks.ts | 30 ++ .../src/shared/support/local-settings.ts | 0 .../src/shared/support/servers/index.ts | 0 .../support/servers/test-graphql-server.ts | 2 +- .../support/servers/test-mongodb-server.ts | 0 .../shared/support/shared-infrastructure.ts | 0 .../src/step-definitions/index.ts | 0 .../acceptance-api/src/world.ts | 47 ++ .../tsconfig.json | 0 .../acceptance-api/turbo.json | 15 + .../acceptance-tests/README.md | 38 -- .../acceptance-tests/package.json | 63 --- .../src/shared/support/cast.ts | 28 -- .../src/shared/support/hooks.ts | 39 -- .../acceptance-tests/src/world.ts | 54 --- .../acceptance-tests/turbo.json | 35 -- .../acceptance-ui/.c8rc.json | 30 ++ .../acceptance-ui/.gitignore | 9 + .../acceptance-ui/README.md | 23 + .../acceptance-ui/cucumber.js | 22 + .../acceptance-ui/package.json | 43 ++ .../abilities/create-listing-ability.ts | 0 .../src/contexts/listing/abilities/index.ts | 0 .../listing/abilities/listing-types.ts | 40 ++ .../listing/questions/listing-status.ts | 74 +++ .../listing/questions/listing-title.ts | 65 +++ .../step-definitions/create-listing.steps.ts | 303 ++++++++++++ .../listing/step-definitions/index.ts | 2 + .../listing/tasks/ui/create-listing.ts | 0 .../create-reservation-request-ability.ts | 0 .../reservation-request/abilities/index.ts | 0 .../abilities/reservation-request-types.ts | 36 ++ ...t-reservation-request-count-for-listing.ts | 0 .../create-reservation-request.steps.ts | 47 +- .../step-definitions/index.ts | 2 + .../tasks/ui/create-reservation-request.ts | 0 .../acceptance-ui/src/shared/support/cast.ts | 13 + .../src/shared/support/domain-test-helpers.ts | 163 +++++++ .../support/formatters/agent-formatter.ts | 135 ++++++ .../acceptance-ui/src/shared/support/hooks.ts | 26 ++ .../shared/support/ui/asset-loader-hooks.mjs | 0 .../src/shared/support/ui/jsdom-setup.ts | 0 .../src/shared/support/ui/react-render.tsx | 0 .../support/ui/register-asset-loader.ts | 0 .../src/shared/support/ui/setup-jsdom.ts | 0 .../src/step-definitions/index.ts | 7 + .../acceptance-ui/src/world.ts | 32 ++ .../acceptance-ui/tsconfig.json | 21 + .../acceptance-ui/turbo.json | 15 + .../test-support/src/scenarios/steps/.gitkeep | 0 pnpm-lock.yaml | 99 +++- sonar-project.properties | 10 +- 77 files changed, 1819 insertions(+), 417 deletions(-) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/.c8rc.json (81%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/.gitignore (100%) create mode 100644 packages/sthrift-verification/acceptance-api/README.md rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/cucumber.js (71%) create mode 100644 packages/sthrift-verification/acceptance-api/package.json rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/abilities/listing-types.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/questions/listing-status.ts (83%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/questions/listing-title.ts (82%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/step-definitions/create-listing.steps.ts (93%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/step-definitions/index.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/listing/tasks/api/create-listing.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/reservation-request/abilities/reservation-request-types.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/reservation-request/step-definitions/index.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/contexts/reservation-request/tasks/api/create-reservation-request.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/abilities/graphql-client.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/application-services/index.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/application-services/real-application-services.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-api/src/shared/support/cast.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/domain-test-helpers.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/formatters/agent-formatter.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/local-settings.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/servers/index.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/servers/test-graphql-server.ts (96%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/servers/test-mongodb-server.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/shared/support/shared-infrastructure.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/src/step-definitions/index.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-api/src/world.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-api}/tsconfig.json (100%) create mode 100644 packages/sthrift-verification/acceptance-api/turbo.json delete mode 100644 packages/sthrift-verification/acceptance-tests/README.md delete mode 100644 packages/sthrift-verification/acceptance-tests/package.json delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/src/world.ts delete mode 100644 packages/sthrift-verification/acceptance-tests/turbo.json create mode 100644 packages/sthrift-verification/acceptance-ui/.c8rc.json create mode 100644 packages/sthrift-verification/acceptance-ui/.gitignore create mode 100644 packages/sthrift-verification/acceptance-ui/README.md create mode 100644 packages/sthrift-verification/acceptance-ui/cucumber.js create mode 100644 packages/sthrift-verification/acceptance-ui/package.json rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/listing/abilities/create-listing-ability.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/listing/abilities/index.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/listing-types.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-status.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-title.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/create-listing.steps.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/index.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/listing/tasks/ui/create-listing.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/reservation-request/abilities/index.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/reservation-request-types.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/reservation-request/questions/domain-get-reservation-request-count-for-listing.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts (88%) create mode 100644 packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/index.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-ui/src/shared/support/cast.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/shared/support/domain-test-helpers.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/shared/support/formatters/agent-formatter.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/shared/support/ui/asset-loader-hooks.mjs (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/shared/support/ui/jsdom-setup.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/shared/support/ui/react-render.tsx (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/shared/support/ui/register-asset-loader.ts (100%) rename packages/sthrift-verification/{acceptance-tests => acceptance-ui}/src/shared/support/ui/setup-jsdom.ts (100%) create mode 100644 packages/sthrift-verification/acceptance-ui/src/step-definitions/index.ts create mode 100644 packages/sthrift-verification/acceptance-ui/src/world.ts create mode 100644 packages/sthrift-verification/acceptance-ui/tsconfig.json create mode 100644 packages/sthrift-verification/acceptance-ui/turbo.json delete mode 100644 packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 0211b3740..acff97ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,12 @@ sharethrift/ │ │ ├── rest/ # REST API adapters │ │ ├── persistence/ # MongoDB persistence layer │ │ ├── application-services/ # Orchestration services -│ │ ├── ui-components/ # Shared React components -│ │ └── acceptance-tests/ # BDD acceptance tests (Serenity.js) +│ │ └── ui-components/ # Shared React components +│ └── sthrift-verification/ # Verification packages and shared test support +│ ├── acceptance-api/ # API-path acceptance tests (Serenity.js) +│ ├── acceptance-ui/ # UI-path acceptance tests (Serenity.js + jsdom) +│ ├── e2e-tests/ # Browser confidence tests (Playwright) +│ └── test-support/ # Shared features, pages, test data │ └── cellix/ # Seedwork abstractions (shared across projects) │ ├── domain-seedwork/ # DDD base classes │ ├── event-bus-seedwork-node/ # Event bus abstractions @@ -327,28 +331,26 @@ All packages use **strict TypeScript configuration**: 3. **Acceptance Tests / BDD** (Top) - Framework: Serenity.js + Cucumber (Gherkin) - - Location: `packages/sthrift/acceptance-tests/` - - Test levels: - - **domain**: Direct domain layer testing - - **session**: GraphQL/MongoDB session management - - **e2e**: Full end-to-end with Playwright browser - - Backends: - - **GraphQL**: GraphQL resolver layer (mock app services) - - **MongoDB**: Full persistence stack (MongoMemoryServer) + - Locations: + - `packages/sthrift-verification/acceptance-api/` + - `packages/sthrift-verification/acceptance-ui/` + - Shared features/pages/test data: + - `packages/sthrift-verification/test-support/` + - Browser confidence tests: + - `packages/sthrift-verification/e2e-tests/` Example test matrix: ``` - Domain (domain) → In-memory aggregates - Session + GraphQL (session:graphql) → GraphQL backend - Session + MongoDB (session:mongodb) → MongoDB backend - E2E (e2e) → Playwright browser → Vite UI → GraphQL → MongoDB + Acceptance API → GraphQL backend → application services → domain → MongoDB + Acceptance UI → jsdom-rendered UI components + shared page objects + E2E → Playwright browser → Vite UI → GraphQL → MongoDB ``` ### Acceptance Test Pattern (Serenity.js) **File Structure**: ``` -acceptance-tests/ +acceptance-api/ or acceptance-ui/ ├── src/ │ ├── step-definitions/ # Gherkin step implementations │ ├── support/ # Hooks, configuration, world setup @@ -358,10 +360,12 @@ acceptance-tests/ │ ├── contexts/ # Test context setup │ │ ├── listing/ # Listing context (abilities, tasks, sessions) │ │ └── reservation-request/ -│ └── features/ # Gherkin feature files (.feature) +│ └── features/ # Optional local feature files (.feature) └── test:* scripts run scenarios ``` +Shared feature files live in `packages/sthrift-verification/test-support/src/scenarios/feature-files/`. + **Key Patterns**: - **Abilities**: Actor capabilities registered in Cast - **Tasks**: Actions the actor takes (calls domain/API) @@ -426,8 +430,8 @@ Located in: `apps/docs/docs/decisions/` 4. **Create repository interface** (abstraction) 5. **Implement UnitOfWork** for consistency coordination 6. **Define permissions** (`{entity}.{role}.passport.ts`) -7. **Create acceptance tests** in `acceptance-tests/src/contexts/{context-name}/` -8. **Add to Cast** in acceptance test world setup +7. **Create acceptance tests** in `acceptance-api/src/contexts/{context-name}/` and/or `acceptance-ui/src/contexts/{context-name}/` +8. **Add to Cast** in the relevant acceptance package world setup 9. **Implement GraphQL resolver** in `packages/sthrift/graphql/` 10. **Implement MongoDB adapter** in `packages/sthrift/persistence/` @@ -442,11 +446,11 @@ Located in: `apps/docs/docs/decisions/` ### Adding a New Acceptance Test -1. **Write feature file** (Gherkin) in `acceptance-tests/src/features/` -2. **Implement step definitions** in `acceptance-tests/src/step-definitions/` +1. **Write feature file** (Gherkin) in `test-support/src/scenarios/feature-files/` +2. **Implement step definitions** in `acceptance-api/src/step-definitions/` and/or `acceptance-ui/src/step-definitions/` 3. **Create abilities/tasks** if needed 4. **Set up world context** in hooks -5. **Run tests**: `pnpm run test:acceptance:domain` (or appropriate level) +5. **Run tests**: `pnpm run test:acceptance:api` and/or `pnpm run test:acceptance:ui` ### Running Acceptance Tests @@ -454,10 +458,10 @@ Located in: `apps/docs/docs/decisions/` # All acceptance tests pnpm run test:acceptance:all -# Specific level -pnpm run test:acceptance:domain # Pure domain -pnpm run test:acceptance:session:graphql # Session + GraphQL -pnpm run test:acceptance:e2e # Full E2E with Playwright +# Specific package +pnpm run test:acceptance:api # API-path acceptance tests +pnpm run test:acceptance:ui # UI-path acceptance tests +pnpm run test:e2e # Full E2E with Playwright ``` ## 🎯 Key Principles diff --git a/knip.json b/knip.json index fdab5f090..fa334f05e 100644 --- a/knip.json +++ b/knip.json @@ -48,7 +48,11 @@ "entry": ["src/index.ts"], "project": ["src/**/*.{ts,tsx}"] }, - "packages/sthrift-verification/acceptance-tests": { + "packages/sthrift-verification/acceptance-api": { + "entry": ["src/world.ts", "src/step-definitions/**/*.ts"], + "project": ["src/**/*.{ts,mjs,js}"] + }, + "packages/sthrift-verification/acceptance-ui": { "entry": ["src/world.ts", "src/step-definitions/**/*.ts"], "project": ["src/**/*.{ts,mjs,js}"] }, @@ -70,7 +74,8 @@ "packages/cellix/server-payment-seedwork", "packages/cellix/arch-unit-tests", "packages/sthrift-verification/arch-unit-tests", - "packages/sthrift-verification/acceptance-tests", + "packages/sthrift-verification/acceptance-api", + "packages/sthrift-verification/acceptance-ui", "packages/sthrift-verification/e2e-tests", "packages/sthrift/ui-components", "apps/server-messaging-mock", diff --git a/package.json b/package.json index 41822bda3..0e670108b 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "start-emulator:payment-server": "pnpm --filter=@cellix/mock-payment-server run start", "start-emulator:messaging-server": "pnpm --filter=@app/mock-messaging-server run start", "test:all": "turbo run test:all", - "test:acceptance:domain": "turbo run test:domain --filter=@sthrift-verification/acceptance-tests", - "test:acceptance:session": "turbo run test:session:graphql --filter=@sthrift-verification/acceptance-tests", - "test:acceptance:session:graphql": "turbo run test:session:graphql --filter=@sthrift-verification/acceptance-tests", - "test:acceptance:session:mongodb": "turbo run test:session:mongodb --filter=@sthrift-verification/acceptance-tests", - "test:acceptance:ui": "turbo run test:ui --filter=@sthrift-verification/acceptance-tests", + "test:acceptance:api": "pnpm --filter=@sthrift-verification/acceptance-api run test", + "test:acceptance:ui": "pnpm --filter=@sthrift-verification/acceptance-ui run test", + "test:acceptance:all": "pnpm run test:acceptance:api && pnpm run test:acceptance:ui", + "test:coverage:acceptance:api": "pnpm --filter=@sthrift-verification/acceptance-api run test:coverage", + "test:coverage:acceptance:ui": "pnpm --filter=@sthrift-verification/acceptance-ui run test:coverage", + "test:coverage:acceptance": "pnpm run test:coverage:acceptance:api && pnpm run test:coverage:acceptance:ui", "test:e2e": "turbo run test:e2e --filter=@sthrift-verification/e2e-tests", "test:coverage": "turbo run test:coverage:ui && turbo run test:coverage:node && turbo run test:arch", "test:coverage:node": "turbo run test:coverage:node", diff --git a/packages/sthrift-verification/acceptance-tests/.c8rc.json b/packages/sthrift-verification/acceptance-api/.c8rc.json similarity index 81% rename from packages/sthrift-verification/acceptance-tests/.c8rc.json rename to packages/sthrift-verification/acceptance-api/.c8rc.json index 0faa5cfa2..5b4a8652b 100644 --- a/packages/sthrift-verification/acceptance-tests/.c8rc.json +++ b/packages/sthrift-verification/acceptance-api/.c8rc.json @@ -1,16 +1,14 @@ { "all": true, "reporter": ["lcov"], - "reportsDirectory": "coverage-c8", + "reportsDirectory": "coverage", "tempDirectory": ".c8-output", "src": [ "../../../packages/sthrift/application-services/src", "../../../packages/sthrift/data-sources-mongoose-models/src", "../../../packages/sthrift/domain/src", "../../../packages/sthrift/graphql/src", - "../../../packages/sthrift/persistence/src", - "../../../apps/ui-sharethrift/src", - "../../../packages/sthrift/ui-components/src" + "../../../packages/sthrift/persistence/src" ], "exclude": [ "cucumber.js", diff --git a/packages/sthrift-verification/acceptance-tests/.gitignore b/packages/sthrift-verification/acceptance-api/.gitignore similarity index 100% rename from packages/sthrift-verification/acceptance-tests/.gitignore rename to packages/sthrift-verification/acceptance-api/.gitignore diff --git a/packages/sthrift-verification/acceptance-api/README.md b/packages/sthrift-verification/acceptance-api/README.md new file mode 100644 index 000000000..32b4f639b --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/README.md @@ -0,0 +1,24 @@ +# ShareThrift Acceptance API Tests + +Cucumber Screenplay acceptance tests for the ShareThrift API path. + +## Scope + +- GraphQL request path +- Application services +- Domain logic +- Persistence with the test MongoDB server + +## Running Tests + +```bash +pnpm run test +pnpm run test:coverage +``` + +## From monorepo root + +```bash +pnpm run test:acceptance:api +pnpm run test:coverage:acceptance:api +``` diff --git a/packages/sthrift-verification/acceptance-tests/cucumber.js b/packages/sthrift-verification/acceptance-api/cucumber.js similarity index 71% rename from packages/sthrift-verification/acceptance-tests/cucumber.js rename to packages/sthrift-verification/acceptance-api/cucumber.js index 7a015e51e..67271519d 100644 --- a/packages/sthrift-verification/acceptance-tests/cucumber.js +++ b/packages/sthrift-verification/acceptance-api/cucumber.js @@ -8,13 +8,12 @@ export default { paths: ['../test-support/src/scenarios/feature-files/**/*.feature'], import: [ 'src/world.ts', - 'src/step-definitions/**/*.ts', - 'src/shared/support/**/*.ts', + 'src/step-definitions/index.ts', ], format: [ terminalFormat, - 'json:./reports/cucumber-report.json', - 'html:./reports/cucumber-report.html', + 'json:./reports/cucumber-report-api.json', + 'html:./reports/cucumber-report-api.html', ], formatOptions: { snippetInterface: 'async-await', diff --git a/packages/sthrift-verification/acceptance-api/package.json b/packages/sthrift-verification/acceptance-api/package.json new file mode 100644 index 000000000..9302c92ca --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/package.json @@ -0,0 +1,48 @@ +{ + "name": "@sthrift-verification/acceptance-api", + "version": "1.0.0", + "description": "Cucumber Screenplay acceptance tests for the ShareThrift API path", + "private": true, + "type": "module", + "scripts": { + "test": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --format json:./reports/cucumber-report-api.json", + "test:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean --allowExternal --reports-dir=coverage --reporter=lcov --reporter=html -- cucumber-js --format json:./reports/cucumber-report-api.json", + "test:coverage:report": "c8 report --allowExternal --temp-directory=.c8-output --reports-dir=coverage --reporter=lcov --reporter=html --exclude='**/node_modules/**' --exclude='**/*.d.ts' --exclude='**/*.stories.*' --exclude='**/generated.*'", + "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" + }, + "dependencies": { + "@cucumber/cucumber": "^12.7.0", + "@serenity-js/assertions": "^3.37.2", + "@serenity-js/console-reporter": "^3.37.2", + "@serenity-js/core": "^3.37.2", + "@serenity-js/cucumber": "^3.37.2", + "@serenity-js/serenity-bdd": "^3.37.2", + "graphql": "^16.12.0", + "std-env": "^4.0.0" + }, + "devDependencies": { + "@apollo/server": "^5.5.0", + "@cellix/service-messaging-base": "workspace:*", + "@cellix/service-mongoose": "workspace:*", + "@cellix/service-payment-base": "workspace:*", + "@cellix/service-token-validation": "workspace:*", + "@cellix/typescript-config": "workspace:*", + "@cucumber/messages": "^32.2.0", + "@sthrift/application-services": "workspace:*", + "@sthrift/context-spec": "workspace:*", + "@sthrift/domain": "workspace:*", + "@sthrift/graphql": "workspace:*", + "@sthrift/persistence": "workspace:*", + "@sthrift-verification/test-support": "workspace:*", + "@types/graphql-depth-limit": "^1.1.6", + "@types/node": "^24.6.1", + "c8": "^11.0.0", + "graphql-depth-limit": "^1.1.0", + "graphql-middleware": "^6.1.35", + "mongodb": "^6.15.0", + "mongodb-memory-server": "^10.2.0", + "rimraf": "^6.0.1", + "tsx": "^4.20.3", + "typescript": "^5.4.5" + } +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/listing-types.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/abilities/listing-types.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/listing-types.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/abilities/listing-types.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-status.ts similarity index 83% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-status.ts index 23421256e..9dc56835c 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-status.ts +++ b/packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-status.ts @@ -6,7 +6,6 @@ import { type UsesAbilities, } from '@serenity-js/core'; import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; -import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; const GET_LISTING_QUERY = ` query GetListing($id: ObjectID!) { @@ -43,11 +42,6 @@ export class ListingStatus extends Question> { return this.normalizeStatus(apiStatus); } - const domainStatus = this.readStatusFromDomain(actor); - if (domainStatus) { - return this.normalizeStatus(domainStatus); - } - const notedStatus = await this.readNote(actor, 'lastListingStatus'); if (!notedStatus) { throw new Error( @@ -80,18 +74,6 @@ export class ListingStatus extends Question> { } } - private readStatusFromDomain( - actor: AnswersQuestions & UsesAbilities, - ): string | undefined { - try { - return CreateListingAbility.as( - actor as unknown as Actor, - ).getCreatedListing()?.state; - } catch { - return undefined; - } - } - private async readNote( actor: AnswersQuestions & UsesAbilities, key: 'lastListingId' | 'lastListingTitle' | 'lastListingStatus', diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-title.ts similarity index 82% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-title.ts index 5ae099f98..168f0fe01 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/questions/listing-title.ts +++ b/packages/sthrift-verification/acceptance-api/src/contexts/listing/questions/listing-title.ts @@ -6,7 +6,6 @@ import { type UsesAbilities, } from '@serenity-js/core'; import { GraphQLClient } from '../../../shared/abilities/graphql-client.ts'; -import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; const GET_LISTING_QUERY = ` query GetListing($id: ObjectID!) { @@ -42,11 +41,6 @@ export class ListingTitle extends Question> { return apiTitle; } - const domainTitle = this.readTitleFromDomain(actor); - if (domainTitle) { - return domainTitle; - } - if (!notedTitle) { throw new Error( 'No listing title found in the system or actor notes. Did the actor create a listing first?', @@ -78,18 +72,6 @@ export class ListingTitle extends Question> { } } - private readTitleFromDomain( - actor: AnswersQuestions & UsesAbilities, - ): string | undefined { - try { - return CreateListingAbility.as( - actor as unknown as Actor, - ).getCreatedListing()?.title; - } catch { - return undefined; - } - } - private async readNote( actor: AnswersQuestions & UsesAbilities, key: 'lastListingId' | 'lastListingTitle', diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/step-definitions/create-listing.steps.ts similarity index 93% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/step-definitions/create-listing.steps.ts index 410afab6f..089ed83c1 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/create-listing.steps.ts +++ b/packages/sthrift-verification/acceptance-api/src/contexts/listing/step-definitions/create-listing.steps.ts @@ -10,16 +10,10 @@ import type { import { ListingStatus } from '../questions/listing-status.ts'; import { ListingTitle } from '../questions/listing-title.ts'; import { CreateListing as ApiCreateListing } from '../tasks/api/create-listing.ts'; -import { CreateListing as UICreateListing } from '../tasks/ui/create-listing.ts'; // Track last actor used in When steps so Then steps can reference them without hardcoding let lastActorName = 'Alice'; -function getCreateListingTask(level: string) { - if (level === 'api') return ApiCreateListing; - return UICreateListing; -} - Given( '{word} is an authenticated user', function (this: ShareThriftWorld, actorName: string) { @@ -33,10 +27,8 @@ Given( async function (this: ShareThriftWorld, actorName: string, title: string) { const actor = actorCalled(actorName); - const CreateListing = getCreateListingTask(this.level); - await actor.attemptsTo( - CreateListing.with({ + ApiCreateListing.with({ title, description: 'Test listing', category: 'Other', @@ -57,10 +49,8 @@ When( const actor = actorCalled(actorName); const details = dataTable.rowsHash(); - const CreateListing = getCreateListingTask(this.level); - await actor.attemptsTo( - CreateListing.with(details as unknown as ListingDetails), + ApiCreateListing.with(details as unknown as ListingDetails), ); }, ); @@ -76,8 +66,6 @@ When( const actor = actorCalled(actorName); const details = dataTable.rowsHash(); - const CreateListing = getCreateListingTask(this.level); - // Clear notes from any previous scenario to prevent state leakage await actor.attemptsTo( notes().set( @@ -92,7 +80,7 @@ When( try { await actor.attemptsTo( - CreateListing.with(details as unknown as ListingDetails), + ApiCreateListing.with(details as unknown as ListingDetails), ); } catch (error) { const errorMessage = diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/index.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/step-definitions/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/step-definitions/index.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/step-definitions/index.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts b/packages/sthrift-verification/acceptance-api/src/contexts/listing/tasks/api/create-listing.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/api/create-listing.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/listing/tasks/api/create-listing.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/reservation-request-types.ts b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/abilities/reservation-request-types.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/reservation-request-types.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/abilities/reservation-request-types.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/questions/get-reservation-request-count-for-listing.ts diff --git a/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts new file mode 100644 index 000000000..97e1f6652 --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts @@ -0,0 +1,442 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { Ensure, equals, includes, isPresent } from '@serenity-js/assertions'; +import { actorCalled, notes } from '@serenity-js/core'; +import { + makeTestUserData, + resolveActorName, +} from '../../../shared/support/domain-test-helpers.ts'; +import type { ShareThriftWorld } from '../../../world.ts'; +import type { ListingDetails } from '../../listing/abilities/listing-types.ts'; +import { CreateListing as ApiCreateListing } from '../../listing/tasks/api/create-listing.ts'; +import type { + CreateReservationRequestInput, + ReservationRequestNotes, +} from '../abilities/reservation-request-types.ts'; +import { GetReservationRequestCountForListing } from '../questions/get-reservation-request-count-for-listing.ts'; +import { CreateReservationRequest as ApiCreateReservationRequest } from '../tasks/api/create-reservation-request.ts'; + +let lastActorName = 'Alice'; + +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); + date.setHours(0, 0, 0, 0); + return date; +} + +function formatDateForComparison(date: Date): string { + return date.toISOString().split('T')[0] ?? ''; +} + +async function getListingIdFromOwner(ownerName: string): Promise { + const owner = actorCalled(ownerName); + const listingId = await owner.answer( + notes<{ lastListingId: string }>().get('lastListingId'), + ); + if (!listingId) { + throw new Error( + `No listing ID found in ${ownerName}'s notes. Did ${ownerName} create a listing first?`, + ); + } + return listingId; +} + +Given( + '{word} has created a listing with:', + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = dataTable.rowsHash(); + + await actor.attemptsTo( + ApiCreateListing.with(details as unknown as ListingDetails), + ); + }, +); + +When( + "{word} creates a reservation request for {word}'s listing with:", + async function ( + this: ShareThriftWorld, + reserver: string, + owner: string, + dataTable: DataTable, + ) { + lastActorName = reserver; + const actor = actorCalled(reserver); + const data = dataTable.rowsHash(); + + const listingId = await getListingIdFromOwner(owner); + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; + + await actor.attemptsTo( + ApiCreateReservationRequest.with({ + listingId, + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), + reserver: makeTestUserData(reserver), + }), + ); + }, +); + +When( + '{word} attempts to create a reservation request with:', + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { + lastActorName = actorName; + const actor = actorCalled(actorName); + const data = dataTable.rowsHash(); + + await actor.attemptsTo( + notes().set( + 'lastReservationRequestId', + undefined as unknown as string, + ), + notes().set( + 'lastReservationRequestState', + undefined as unknown as string, + ), + notes().set( + 'lastValidationError', + undefined as unknown as string, + ), + ); + + try { + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; + + const listingId = await getListingIdFromOwner('Bob'); + + const input: Partial = { + listingId, + reserver: makeTestUserData(actorName), + }; + + if (startDate) { + input.reservationPeriodStart = parseDateInput(String(startDate)); + } + if (endDate) { + input.reservationPeriodEnd = parseDateInput(String(endDate)); + } + + await actor.attemptsTo( + ApiCreateReservationRequest.with( + input as CreateReservationRequestInput, + ), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes().set( + 'lastValidationError', + errorMessage, + ), + ); + } + }, +); + +Then( + 'the reservation request should be in requested status', + async function (this: ShareThriftWorld) { + const actor = actorCalled(lastActorName); + + await actor.attemptsTo( + Ensure.that( + notes().get('lastReservationRequestId'), + isPresent(), + ), + Ensure.that( + notes().get('lastReservationRequestState'), + equals('Requested'), + ), + ); + }, +); + +Then( + 'the reservation request should have a start date of {string}', + async function (this: ShareThriftWorld, expectedDate: string) { + const actor = actorCalled(lastActorName); + + await actor.attemptsTo( + Ensure.that( + notes().get('lastReservationRequestStartDate'), + equals(expectedDate), + ), + ); + }, +); + +Then( + 'the reservation request should have a start date that is {int} day(s) from now', + async function (this: ShareThriftWorld, daysFromNow: number) { + const actor = actorCalled(lastActorName); + const expectedDate = new Date(); + expectedDate.setDate(expectedDate.getDate() + daysFromNow); + const expectedDateStr = formatDateForComparison(expectedDate); + + await actor.attemptsTo( + Ensure.that( + notes().get('lastReservationRequestStartDate'), + equals(expectedDateStr), + ), + ); + }, +); + +Then( + 'the reservation request should have an end date of {string}', + async function (this: ShareThriftWorld, expectedDate: string) { + const actor = actorCalled(lastActorName); + + await actor.attemptsTo( + Ensure.that( + notes().get('lastReservationRequestEndDate'), + equals(expectedDate), + ), + ); + }, +); + +Then( + 'the reservation request should have an end date that is {int} day(s) from now', + async function (this: ShareThriftWorld, daysFromNow: number) { + const actor = actorCalled(lastActorName); + const expectedDate = new Date(); + expectedDate.setDate(expectedDate.getDate() + daysFromNow); + const expectedDateStr = formatDateForComparison(expectedDate); + + await actor.attemptsTo( + Ensure.that( + notes().get('lastReservationRequestEndDate'), + equals(expectedDateStr), + ), + ); + }, +); + +Then( + '{word} should see a reservation error for {string}', + async function ( + this: ShareThriftWorld, + actorName: string, + fieldName: string, + ) { + const resolvedActorName = resolveActorName(actorName); + const actor = actorCalled(resolvedActorName); + + const storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); + if (!storedError) { + throw new Error( + `Expected a validation error for "${fieldName}" but no error was captured`, + ); + } + + const lowerError = storedError.toLowerCase(); + const lowerField = fieldName.toLowerCase(); + const isFieldMentioned = lowerError.includes(lowerField); + const isValidationPattern = + /required|missing|invalid|cannot read properties of undefined|wrong raw value type/i.test( + storedError, + ); + + if (!isFieldMentioned && !isValidationPattern) { + throw new Error( + `Expected a validation error related to "${fieldName}", but got an unrecognized error: "${storedError}"`, + ); + } + + let requestId: string | undefined; + try { + requestId = await actor.answer( + notes().get('lastReservationRequestId'), + ); + } catch { + // expected + } + if (requestId) { + throw new Error( + `Expected reservation creation to be blocked by "${fieldName}" validation, ` + + `but a request was created with id: ${requestId}`, + ); + } + }, +); + +Then( + '{word} should see a reservation error {string}', + async function ( + this: ShareThriftWorld, + actorName: string, + expectedMessage: string, + ) { + const resolvedActorName = resolveActorName(actorName); + const actor = actorCalled(resolvedActorName); + + await actor.attemptsTo( + Ensure.that( + notes<{ lastValidationError: string }>().get('lastValidationError'), + includes(expectedMessage), + ), + ); + }, +); + +Then( + 'no reservation request should be created', + async function (this: ShareThriftWorld) { + const actor = actorCalled(lastActorName); + + let hasValidationError = false; + try { + const storedError = await actor.answer( + notes().get('lastValidationError'), + ); + hasValidationError = !!storedError; + } catch { + // No error stored + } + + let requestId: string | undefined; + try { + requestId = await actor.answer( + notes().get('lastReservationRequestId'), + ); + } catch { + // No ID — expected + } + + if (requestId) { + throw new Error( + `Expected no reservation request to be created, but one was created with id: ${requestId}`, + ); + } + + if (!hasValidationError) { + throw new Error( + 'Expected a validation error to prevent reservation creation, but no error was captured. ' + + 'The test may be passing without actually validating the scenario.', + ); + } + }, +); + +Then( + 'only one reservation request should exist for the listing', + async function (this: ShareThriftWorld) { + const actor = actorCalled(lastActorName); + const listingId = await getListingIdFromOwner('Bob'); + const countQuestion = + GetReservationRequestCountForListing.forListing(listingId); + + await actor.attemptsTo(Ensure.that(countQuestion, equals(1))); + }, +); + +Given( + "{word} has already created a reservation request for {word}'s listing with:", + async function ( + this: ShareThriftWorld, + reserver: string, + owner: string, + dataTable: DataTable, + ) { + lastActorName = reserver; + const actor = actorCalled(reserver); + const data = dataTable.rowsHash(); + + const listingId = await getListingIdFromOwner(owner); + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; + + await actor.attemptsTo( + ApiCreateReservationRequest.with({ + listingId, + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), + reserver: makeTestUserData(reserver), + }), + ); + }, +); + +When( + '{word} attempts to create another reservation request for the same listing with:', + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { + lastActorName = actorName; + const actor = actorCalled(actorName); + const data = dataTable.rowsHash(); + + await actor.attemptsTo( + notes<{ lastValidationError?: string }>().set( + 'lastValidationError', + undefined as unknown as string, + ), + ); + + try { + const listingId = await getListingIdFromOwner('Bob'); + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; + + await actor.attemptsTo( + ApiCreateReservationRequest.with({ + listingId, + reservationPeriodStart: startDate + ? parseDateInput(String(startDate)) + : new Date(), + reservationPeriodEnd: endDate + ? parseDateInput(String(endDate)) + : new Date(), + reserver: { + id: 'test-user-1', + email: `${actorName.toLowerCase()}@test.com`, + firstName: actorName, + lastName: 'Tester', + }, + }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes<{ lastValidationError?: string }>().set( + 'lastValidationError', + errorMessage, + ), + ); + } + }, +); diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/index.ts b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/index.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/step-definitions/index.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts b/packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/tasks/api/create-reservation-request.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/api/create-reservation-request.ts rename to packages/sthrift-verification/acceptance-api/src/contexts/reservation-request/tasks/api/create-reservation-request.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts b/packages/sthrift-verification/acceptance-api/src/shared/abilities/graphql-client.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/abilities/graphql-client.ts rename to packages/sthrift-verification/acceptance-api/src/shared/abilities/graphql-client.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/application-services/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/index.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/application-services/index.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/application-services/real-application-services.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/application-services/real-application-services.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/application-services/real-application-services.ts diff --git a/packages/sthrift-verification/acceptance-api/src/shared/support/cast.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/cast.ts new file mode 100644 index 000000000..cbbc3fd4b --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/src/shared/support/cast.ts @@ -0,0 +1,13 @@ +import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; +import { GraphQLClient } from '../abilities/graphql-client.ts'; + +export class ShareThriftApiCast 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/sthrift-verification/acceptance-tests/src/shared/support/domain-test-helpers.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/domain-test-helpers.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/domain-test-helpers.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/domain-test-helpers.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/formatters/agent-formatter.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/formatters/agent-formatter.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/formatters/agent-formatter.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/formatters/agent-formatter.ts diff --git a/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts new file mode 100644 index 000000000..a03c5d6df --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts @@ -0,0 +1,30 @@ +import type { IWorld } from '@cucumber/cucumber'; +import { After, AfterAll, Before, setDefaultTimeout } from '@cucumber/cucumber'; +import { isAgent } from 'std-env'; +import { type ShareThriftApiWorld, stopSharedServers } from '../../world.ts'; + +let printedSuiteHeader = false; + +setDefaultTimeout(120_000); + +Before(async function (this: IWorld) { + const world = this as IWorld & ShareThriftApiWorld; + + if (!printedSuiteHeader && !isAgent) { + printedSuiteHeader = true; + console.log('\nAPI acceptance tests'); + console.log(' - Listing context'); + console.log(' - Reservation request context\n'); + } + + await world.init(); +}); + +After(async function (this: IWorld) { + const world = this as IWorld & ShareThriftApiWorld; + await world.cleanup(); +}); + +AfterAll(async function () { + await stopSharedServers(); +}); diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/local-settings.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/local-settings.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/index.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/servers/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/servers/index.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/servers/index.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-graphql-server.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/servers/test-graphql-server.ts similarity index 96% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-graphql-server.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/servers/test-graphql-server.ts index ab5387a5f..fe833b722 100644 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-graphql-server.ts +++ b/packages/sthrift-verification/acceptance-api/src/shared/support/servers/test-graphql-server.ts @@ -14,7 +14,7 @@ interface GraphContext { const MAX_QUERY_DEPTH = 10; -// In-process Apollo Server for session-level and integration tests +// In-process Apollo Server for API acceptance and integration tests export class GraphQLTestServer { private server: ApolloServer | null = null; private url: string | null = null; diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/servers/test-mongodb-server.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/servers/test-mongodb-server.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/servers/test-mongodb-server.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/shared-infrastructure.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/shared-infrastructure.ts rename to packages/sthrift-verification/acceptance-api/src/shared/support/shared-infrastructure.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/step-definitions/index.ts b/packages/sthrift-verification/acceptance-api/src/step-definitions/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/step-definitions/index.ts rename to packages/sthrift-verification/acceptance-api/src/step-definitions/index.ts diff --git a/packages/sthrift-verification/acceptance-api/src/world.ts b/packages/sthrift-verification/acceptance-api/src/world.ts new file mode 100644 index 000000000..7628648ad --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/src/world.ts @@ -0,0 +1,47 @@ +import { + setWorldConstructor, + World, + type IWorldOptions, +} from '@cucumber/cucumber'; +import { engage } from '@serenity-js/core'; +import { + clearMockListings, + clearMockReservationRequests, +} from '@sthrift-verification/test-support/test-data'; +import './shared/support/hooks.ts'; +import { ShareThriftApiCast } from './shared/support/cast.ts'; +import * as infra from './shared/support/shared-infrastructure.ts'; + +export async function stopSharedServers(): Promise { + await infra.stopAll(); +} + +export class ShareThriftApiWorld extends World { + private apiUrl = ''; + + constructor(options: IWorldOptions) { + super(options); + } + + async init(): Promise { + await infra.ensureApiServers(); + + const { apiUrl } = infra.getState(); + if (apiUrl) { + this.apiUrl = apiUrl; + } + + clearMockReservationRequests(); + clearMockListings(); + + engage(new ShareThriftApiCast(this.apiUrl)); + } + + async cleanup(): Promise { + // No cleanup needed per scenario. + } +} + +export { ShareThriftApiWorld as ShareThriftWorld }; + +setWorldConstructor(ShareThriftApiWorld); diff --git a/packages/sthrift-verification/acceptance-tests/tsconfig.json b/packages/sthrift-verification/acceptance-api/tsconfig.json similarity index 100% rename from packages/sthrift-verification/acceptance-tests/tsconfig.json rename to packages/sthrift-verification/acceptance-api/tsconfig.json diff --git a/packages/sthrift-verification/acceptance-api/turbo.json b/packages/sthrift-verification/acceptance-api/turbo.json new file mode 100644 index 000000000..1682c93b5 --- /dev/null +++ b/packages/sthrift-verification/acceptance-api/turbo.json @@ -0,0 +1,15 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["^build"], + "inputs": ["src/**", "cucumber.js"], + "outputs": ["reports/**"] + }, + "test:coverage": { + "dependsOn": ["^build"], + "inputs": ["src/**", "cucumber.js", ".c8rc.json"], + "outputs": ["reports/**", "coverage-c8/**", ".c8-output/**"] + } + } +} diff --git a/packages/sthrift-verification/acceptance-tests/README.md b/packages/sthrift-verification/acceptance-tests/README.md deleted file mode 100644 index e86f32c01..000000000 --- a/packages/sthrift-verification/acceptance-tests/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# ShareThrift Acceptance Tests - -Cucumber Screenplay pattern acceptance tests for the ShareThrift domain, implementing domain and session level testing. - -## Test Levels - -| Level | What It Tests | Speed | Stack | -|-------|---------------|-------|-------| -| **Domain** | Pure business logic | ⚡ Milliseconds | In-memory aggregates | -| **Session** | API contracts (GraphQL/MongoDB) | 🏃 Sub-second | Apollo TestServer + MongoMemoryServer | - -## Running Tests - -```bash -# Domain tests (fastest) -pnpm run test:domain - -# Session tests with GraphQL backend -pnpm run test:session:graphql - -# Session tests with MongoDB backend -pnpm run test:session:mongodb - -# Fast suite (domain + session:graphql) -pnpm run test:fast - -# All acceptance tests -pnpm run test:all -``` - -## From monorepo root - -```bash -pnpm run test:acceptance:domain -pnpm run test:acceptance:session:graphql -pnpm run test:acceptance:session:mongodb -pnpm run test:acceptance:fast -``` diff --git a/packages/sthrift-verification/acceptance-tests/package.json b/packages/sthrift-verification/acceptance-tests/package.json deleted file mode 100644 index 2d4e6b622..000000000 --- a/packages/sthrift-verification/acceptance-tests/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@sthrift-verification/acceptance-tests", - "version": "1.0.0", - "description": "Cucumber Screenplay acceptance tests for ShareThrift (api + ui levels)", - "private": true, - "type": "module", - "scripts": { - "test:api": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --world-parameters '{\"tasks\":\"api\"}' --format json:./reports/cucumber-report-api.json", - "test:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", - "test:coverage:api": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' c8 --clean -- cucumber-js --world-parameters '{\"tasks\":\"api\"}' --format json:./reports/cucumber-report-api.json", - "test:coverage:ui": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 --clean=false -- cucumber-js --world-parameters '{\"tasks\":\"ui\"}' --format json:./reports/cucumber-report-ui.json", - "test:coverage:acceptance": "pnpm run test:coverage:api && pnpm run test:coverage:ui && pnpm run test:coverage:report", - "test:coverage:report": "c8 report --allowExternal --temp-directory=.c8-output --reports-dir=coverage-c8 --reporter=lcov --exclude='**/node_modules/**' --exclude='**/*.d.ts' --exclude='**/*.stories.*' --exclude='**/generated.*'", - "test:all": "pnpm run test:api && pnpm run test:ui", - "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" - }, - "dependencies": { - "@cucumber/cucumber": "^12.7.0", - "@serenity-js/assertions": "^3.37.2", - "@serenity-js/console-reporter": "^3.37.2", - "@serenity-js/core": "^3.37.2", - "@serenity-js/cucumber": "^3.37.2", - "@serenity-js/serenity-bdd": "^3.37.2", - "graphql": "^16.12.0", - "std-env": "^4.0.0" - }, - "devDependencies": { - "@ant-design/icons": "^6.1.0", - "@apollo/server": "^5.5.0", - "@apps/ui-sharethrift": "workspace:*", - "@cellix/service-messaging-base": "workspace:*", - "@cellix/service-mongoose": "workspace:*", - "@cellix/service-payment-base": "workspace:*", - "@cellix/service-token-validation": "workspace:*", - "@cellix/typescript-config": "workspace:*", - "@cucumber/messages": "^32.2.0", - "@sthrift/application-services": "workspace:*", - "@sthrift/context-spec": "workspace:*", - "@sthrift/domain": "workspace:*", - "@sthrift/graphql": "workspace:*", - "@sthrift/persistence": "workspace:*", - "@sthrift/ui-components": "workspace:*", - "@sthrift-verification/test-support": "workspace:*", - "@testing-library/react": "^16.3.2", - "@types/graphql-depth-limit": "^1.1.6", - "@types/jsdom": "^21.1.7", - "@types/node": "^24.6.1", - "antd": "^5.27.0", - "c8": "^11.0.0", - "dayjs": "^1.11.0", - "graphql-depth-limit": "^1.1.0", - "graphql-middleware": "^6.1.35", - "jsdom": "^26.1.0", - "mongodb": "^6.15.0", - "mongodb-memory-server": "^10.2.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router-dom": "^7.12.0", - "rimraf": "^6.0.1", - "tsx": "^4.20.3", - "typescript": "^5.4.5" - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts deleted file mode 100644 index 4a385f15b..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/cast.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; -import { listingAbilities } from '../../contexts/listing/abilities/index.ts'; -import { reservationRequestAbilities } from '../../contexts/reservation-request/abilities/index.ts'; -import type { TaskLevel } from '../../world.ts'; -import { GraphQLClient } from '../abilities/graphql-client.ts'; - -export class ShareThriftCast implements Cast { - constructor( - private readonly tasksLevel: TaskLevel, - private readonly apiUrl: string, - ) {} - - prepare(actor: Actor): Actor { - if (this.tasksLevel === 'ui') { - return actor.whoCan( - TakeNotes.using(Notepad.empty()), - ...listingAbilities.create(), - ...reservationRequestAbilities.create(), - ); - } - - // api level: full stack via GraphQL → app-services → domain → persistence (MongoDB) - return actor.whoCan( - TakeNotes.using(Notepad.empty()), - GraphQLClient.at(this.apiUrl), - ); - } -} diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts deleted file mode 100644 index 1b13d0c02..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/shared/support/hooks.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IWorld } from '@cucumber/cucumber'; -import { After, AfterAll, Before, setDefaultTimeout } from '@cucumber/cucumber'; -import { isAgent } from 'std-env'; - -import { type ShareThriftWorld, stopSharedServers } from '../../world.ts'; - -let lastTestConfig: string | undefined; - -setDefaultTimeout(120_000); - -Before(async function (this: IWorld<{ tasks?: string }>) { - const world = this as IWorld<{ tasks?: string }> & ShareThriftWorld; - - const testConfig = world.level; - - if (lastTestConfig !== testConfig) { - lastTestConfig = testConfig; - - if (!isAgent) { - const levelIcon = world.level === 'api' ? '📡' : '🖥️'; - const testLevelStr = world.level.toUpperCase(); - - console.log(`\n${levelIcon} ${testLevelStr} tests`); - console.log(' • Listing Context'); - console.log(' • Reservation Request Context\n'); - } - } - - await world.init(); -}); - -After(async function (this: IWorld<{ tasks?: string }>) { - const world = this as IWorld<{ tasks?: string }> & ShareThriftWorld; - await world.cleanup(); -}); - -AfterAll(async function () { - await stopSharedServers(); -}); diff --git a/packages/sthrift-verification/acceptance-tests/src/world.ts b/packages/sthrift-verification/acceptance-tests/src/world.ts deleted file mode 100644 index b2f8c01d1..000000000 --- a/packages/sthrift-verification/acceptance-tests/src/world.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { setWorldConstructor, World, type IWorldOptions } from '@cucumber/cucumber'; -import { engage } from '@serenity-js/core'; -import './shared/support/hooks.ts'; -import { ShareThriftCast } from './shared/support/cast.ts'; -import { clearMockListings, clearMockReservationRequests } from '@sthrift-verification/test-support/test-data'; -import * as infra from './shared/support/shared-infrastructure.ts'; - -export type TaskLevel = 'api' | 'ui'; - -export interface WorldParameters { - tasks: TaskLevel; -} - -export async function stopSharedServers(): Promise { - await infra.stopAll(); -} - -export class ShareThriftWorld extends World { - private readonly tasksLevel: TaskLevel; - private apiUrl: string; - - constructor(options: IWorldOptions) { - super(options); - this.tasksLevel = options.parameters?.tasks || 'api'; - this.apiUrl = ''; - } - - async init(): Promise { - if (this.tasksLevel === 'api') { - await infra.ensureApiServers(); - } - - const { apiUrl } = infra.getState(); - - if (apiUrl) { - this.apiUrl = apiUrl; - } - - clearMockReservationRequests(); - clearMockListings(); - - engage(new ShareThriftCast(this.tasksLevel, this.apiUrl)); - } - - async cleanup(): Promise { - // No cleanup needed - } - - get level(): TaskLevel { - return this.tasksLevel; - } -} - -setWorldConstructor(ShareThriftWorld); diff --git a/packages/sthrift-verification/acceptance-tests/turbo.json b/packages/sthrift-verification/acceptance-tests/turbo.json deleted file mode 100644 index 90f3395d3..000000000 --- a/packages/sthrift-verification/acceptance-tests/turbo.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "extends": ["//"], - "tasks": { - "test:domain": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - }, - "test:session:graphql": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - }, - "test:session:mongodb": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - }, - "test:fast": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - }, - "test:all": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - }, - "test:ui": { - "dependsOn": ["^build"], - "inputs": ["src/**", "cucumber.js"], - "outputs": ["reports/**"] - } - } -} diff --git a/packages/sthrift-verification/acceptance-ui/.c8rc.json b/packages/sthrift-verification/acceptance-ui/.c8rc.json new file mode 100644 index 000000000..1b43b0c25 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/.c8rc.json @@ -0,0 +1,30 @@ +{ + "all": true, + "reporter": ["lcov"], + "reportsDirectory": "coverage", + "tempDirectory": ".c8-output", + "src": [ + "../../../apps/ui-sharethrift/src", + "../../../packages/sthrift/ui-components/src" + ], + "exclude": [ + "cucumber.js", + "src/**", + "**/node_modules/**", + "**/arch-unit-tests/**", + "**/*.test.*", + "**/*.spec.*", + "**/*.stories.*", + "**/*.d.ts", + "**/dist/**/*.map", + "**/packages/sthrift-verification/**", + "**/packages/cellix/test-utils/**", + "**/packages/cellix/vitest-config/**", + "coverage/**", + "coverage-c8/**", + "coverage-vitest/**", + ".c8-output/**", + "**/generated.tsx" + ], + "excludeNodeModules": false +} diff --git a/packages/sthrift-verification/acceptance-ui/.gitignore b/packages/sthrift-verification/acceptance-ui/.gitignore new file mode 100644 index 000000000..8072fd279 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/.gitignore @@ -0,0 +1,9 @@ +dist/ +node_modules/ +reports/ +target/ +*.log +.portless/ +.c8-output/ +coverage/ +coverage-c8 diff --git a/packages/sthrift-verification/acceptance-ui/README.md b/packages/sthrift-verification/acceptance-ui/README.md new file mode 100644 index 000000000..086673d46 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/README.md @@ -0,0 +1,23 @@ +# ShareThrift Acceptance UI Tests + +Cucumber Screenplay acceptance tests for the ShareThrift UI component path. + +## Scope + +- jsdom-rendered UI components +- shared page objects from `test-support` +- domain-backed in-memory validation/assertion helpers + +## Running Tests + +```bash +pnpm run test +pnpm run test:coverage +``` + +## From monorepo root + +```bash +pnpm run test:acceptance:ui +pnpm run test:coverage:acceptance:ui +``` diff --git a/packages/sthrift-verification/acceptance-ui/cucumber.js b/packages/sthrift-verification/acceptance-ui/cucumber.js new file mode 100644 index 000000000..ab792127d --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/cucumber.js @@ -0,0 +1,22 @@ +import { isAgent } from 'std-env'; + +const terminalFormat = isAgent + ? './src/shared/support/formatters/agent-formatter.ts' + : 'progress-bar'; + +export default { + paths: ['../test-support/src/scenarios/feature-files/**/*.feature'], + import: [ + 'src/world.ts', + 'src/step-definitions/index.ts', + ], + format: [ + terminalFormat, + 'json:./reports/cucumber-report-ui.json', + 'html:./reports/cucumber-report-ui.html', + ], + formatOptions: { + snippetInterface: 'async-await', + }, + parallel: 1, +}; diff --git a/packages/sthrift-verification/acceptance-ui/package.json b/packages/sthrift-verification/acceptance-ui/package.json new file mode 100644 index 000000000..2ea95fa4a --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/package.json @@ -0,0 +1,43 @@ +{ + "name": "@sthrift-verification/acceptance-ui", + "version": "1.0.0", + "description": "Cucumber Screenplay acceptance tests for the ShareThrift UI component path", + "private": true, + "type": "module", + "scripts": { + "test": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' cucumber-js --format json:./reports/cucumber-report-ui.json", + "test:coverage": "LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm --import ./src/shared/support/ui/register-asset-loader.ts' c8 --clean --allowExternal --reports-dir=coverage --reporter=lcov --reporter=html -- cucumber-js --format json:./reports/cucumber-report-ui.json", + "test:coverage:report": "c8 report --allowExternal --temp-directory=.c8-output --reports-dir=coverage --reporter=lcov --reporter=html --exclude='**/node_modules/**' --exclude='**/*.d.ts' --exclude='**/*.stories.*' --exclude='**/generated.*'", + "clean": "rimraf dist reports target coverage coverage-c8 coverage-vitest .c8-output" + }, + "dependencies": { + "@cucumber/cucumber": "^12.7.0", + "@serenity-js/assertions": "^3.37.2", + "@serenity-js/console-reporter": "^3.37.2", + "@serenity-js/core": "^3.37.2", + "@serenity-js/cucumber": "^3.37.2", + "@serenity-js/serenity-bdd": "^3.37.2", + "std-env": "^4.0.0" + }, + "devDependencies": { + "@ant-design/icons": "^6.1.0", + "@apps/ui-sharethrift": "workspace:*", + "@cellix/typescript-config": "workspace:*", + "@cucumber/messages": "^32.2.0", + "@sthrift/domain": "workspace:*", + "@sthrift/ui-components": "workspace:*", + "@sthrift-verification/test-support": "workspace:*", + "@testing-library/react": "^16.3.2", + "@types/jsdom": "^21.1.7", + "@types/node": "^24.6.1", + "antd": "^5.27.0", + "c8": "^11.0.0", + "jsdom": "^26.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.12.0", + "rimraf": "^6.0.1", + "tsx": "^4.20.3", + "typescript": "^5.4.5" + } +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/create-listing-ability.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/create-listing-ability.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/create-listing-ability.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/index.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/abilities/index.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/index.ts diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/listing-types.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/listing-types.ts new file mode 100644 index 000000000..73aaade64 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/abilities/listing-types.ts @@ -0,0 +1,40 @@ +export interface ListingNotes { + lastListingId: string; + lastListingTitle: string; + lastListingStatus: string; + lastValidationError: string; +} + +export interface ListingDetails { + title: string; + description: string; + category: string; + location: string; + weeklyRate?: string; + deposit?: string; + tags?: string; + isDraft?: boolean | string; +} + +export interface CreateItemListingInput { + title: string; + description: string; + category: string; + location: string; + sharingPeriodStart: Date; + sharingPeriodEnd: Date; + images?: string[]; + isDraft?: boolean; +} + +export interface ItemListingResponse { + id: string; + title: string; + description: string; + category: string; + location: string; + state: 'Draft' | 'Active'; + sharingPeriodStart: Date; + sharingPeriodEnd: Date; + images: string[]; +} diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-status.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-status.ts new file mode 100644 index 000000000..df903bd6a --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-status.ts @@ -0,0 +1,74 @@ +import { + type AnswersQuestions, + notes, + Question, + type UsesAbilities, +} from '@serenity-js/core'; +import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; + +export class ListingStatus extends Question> { + constructor() { + super('listing status'); + } + + override answeredBy( + actor: AnswersQuestions & UsesAbilities, + ): Promise { + return this.resolveStatus(actor); + } + + static of(): ListingStatus { + return new ListingStatus(); + } + + override toString(): string { + return 'the listing status'; + } + + private async resolveStatus( + actor: AnswersQuestions & UsesAbilities, + ): Promise { + const domainStatus = this.readStatusFromDomain(actor); + if (domainStatus) { + return this.normalizeStatus(domainStatus); + } + + const notedStatus = await this.readNote(actor, 'lastListingStatus'); + if (!notedStatus) { + throw new Error( + 'No listing status found in the system or actor notes. Did the actor create a listing first?', + ); + } + + return this.normalizeStatus(notedStatus); + } + + private readStatusFromDomain( + actor: AnswersQuestions & UsesAbilities, + ): string | undefined { + try { + return CreateListingAbility.as(actor).getCreatedListing()?.state; + } catch { + return undefined; + } + } + + private async readNote( + actor: AnswersQuestions & UsesAbilities, + key: 'lastListingId' | 'lastListingTitle' | 'lastListingStatus', + ): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } + + private normalizeStatus(status: string): string { + const normalized = status.trim().toLowerCase(); + if (normalized === 'published') { + return 'active'; + } + return normalized; + } +} diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-title.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-title.ts new file mode 100644 index 000000000..b4797c19c --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/questions/listing-title.ts @@ -0,0 +1,65 @@ +import { + type AnswersQuestions, + notes, + Question, + type UsesAbilities, +} from '@serenity-js/core'; +import { CreateListingAbility } from '../abilities/create-listing-ability.ts'; + +export class ListingTitle extends Question> { + constructor() { + super('listing title'); + } + + static displayed(): ListingTitle { + return new ListingTitle(); + } + + override answeredBy( + actor: AnswersQuestions & UsesAbilities, + ): Promise { + return this.resolveTitle(actor); + } + + override toString = () => 'listing title'; + + private async resolveTitle( + actor: AnswersQuestions & UsesAbilities, + ): Promise { + const notedTitle = await this.readNote(actor, 'lastListingTitle'); + + const domainTitle = this.readTitleFromDomain(actor); + if (domainTitle) { + return domainTitle; + } + + if (!notedTitle) { + throw new Error( + 'No listing title found in the system or actor notes. Did the actor create a listing first?', + ); + } + + return notedTitle; + } + + private readTitleFromDomain( + actor: AnswersQuestions & UsesAbilities, + ): string | undefined { + try { + return CreateListingAbility.as(actor).getCreatedListing()?.title; + } catch { + return undefined; + } + } + + private async readNote( + actor: AnswersQuestions & UsesAbilities, + key: 'lastListingId' | 'lastListingTitle', + ): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/create-listing.steps.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/create-listing.steps.ts new file mode 100644 index 000000000..c4e929942 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/create-listing.steps.ts @@ -0,0 +1,303 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +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 { ShareThriftWorld } from '../../../world.ts'; +import type { + ListingDetails, + ListingNotes, +} from '../abilities/listing-types.ts'; +import { ListingStatus } from '../questions/listing-status.ts'; +import { ListingTitle } from '../questions/listing-title.ts'; +import { CreateListing as UICreateListing } from '../tasks/ui/create-listing.ts'; + +// Track last actor used in When steps so Then steps can reference them without hardcoding +let lastActorName = 'Alice'; + +Given( + '{word} is an authenticated user', + function (this: ShareThriftWorld, actorName: string) { + lastActorName = actorName; + actorCalled(actorName); + }, +); + +Given( + '{word} has created a draft listing titled {string}', + async function (this: ShareThriftWorld, actorName: string, title: string) { + const actor = actorCalled(actorName); + + await actor.attemptsTo( + UICreateListing.with({ + title, + description: 'Test listing', + category: 'Other', + location: 'Test Location', + }), + ); + }, +); + +When( + '{word} creates a listing with:', + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = dataTable.rowsHash(); + + await actor.attemptsTo( + UICreateListing.with(details as unknown as ListingDetails), + ); + }, +); + +When( + '{word} attempts to create a listing with:', + async function ( + this: ShareThriftWorld, + actorName: string, + dataTable: DataTable, + ) { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = dataTable.rowsHash(); + + // Clear notes from any previous scenario to prevent state leakage + await actor.attemptsTo( + notes().set( + 'lastListingId', + undefined as unknown as string, + ), + notes().set( + 'lastValidationError', + undefined as unknown as string, + ), + ); + + try { + await actor.attemptsTo( + UICreateListing.with(details as unknown as ListingDetails), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes<{ lastValidationError: string }>().set( + 'lastValidationError', + errorMessage, + ), + ); + } + }, +); + +Then( + '{word} sees the listing in {word} status', + async function ( + this: ShareThriftWorld, + actorName: string, + expectedStatus: string, + ) { + const actor = actorCalled(actorName); + + await actor.attemptsTo( + Ensure.that(ListingStatus.of(), equals(expectedStatus)), + ); + }, +); + +Then( + '{word} sees the listing title as {string}', + async function ( + this: ShareThriftWorld, + actorName: string, + expectedTitle: string, + ) { + const actor = actorCalled(actorName); + + await actor.attemptsTo( + Ensure.that(ListingTitle.displayed(), equals(expectedTitle)), + ); + }, +); + +Then( + 'the listing should have a daily rate of {string}', + async function (this: ShareThriftWorld, expectedRate: string) { + const actor = actorCalled(lastActorName); + + // Verify listing was created and is in the expected state + const listingId = await actor.answer( + notes().get('lastListingId'), + ); + if (!listingId) { + throw new Error( + 'Expected a listing to exist before checking its daily rate', + ); + } + + // Verify listing is in draft status (confirms the full creation path worked) + await actor.attemptsTo(Ensure.that(ListingStatus.of(), equals('draft'))); + + // TODO: Verify actual daily rate value once domain model exposes it via notes. + if (!expectedRate) { + throw new Error('Expected rate must be provided'); + } + }, +); + +Then( + '{word} should see a listing error for {string}', + async function ( + this: ShareThriftWorld, + actorName: string, + fieldName: string, + ) { + const resolvedActorName = resolveActorName(actorName); + const actor = actorCalled(resolvedActorName); + + // Check stored validation error from task execution (domain/session levels) + let storedError: string | undefined; + try { + storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); + } catch { + // No error in notes + } + + if (storedError) { + const lowerError = storedError.toLowerCase(); + const lowerField = fieldName.toLowerCase(); + const isFieldMentioned = lowerError.includes(lowerField); + const isValidationPattern = + /wrong raw value type|cannot be empty|required|missing|invalid/i.test( + storedError, + ); + + if (!isFieldMentioned && !isValidationPattern) { + throw new Error( + `Expected a validation error related to "${fieldName}", but got an unrecognized error: "${storedError}"`, + ); + } + + let listingId: string | undefined; + try { + listingId = await actor.answer( + notes().get('lastListingId'), + ); + } catch { + // expected + } + if (listingId) { + throw new Error( + `Expected listing creation to be blocked by "${fieldName}" validation, ` + + `but a listing was created with id: ${listingId}`, + ); + } + + return; + } + + throw new Error( + `Expected a validation error for "${fieldName}" but none was found`, + ); + }, +); + +Then( + '{word} should see a listing error {string}', + async function ( + this: ShareThriftWorld, + actorName: string, + expectedMessage: string, + ) { + const resolvedActorName = resolveActorName(actorName); + const actor = actorCalled(resolvedActorName); + + let storedError: string | undefined; + try { + storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); + } catch { + // No error stored + } + + if (storedError) { + if (!storedError.includes(expectedMessage)) { + throw new Error( + `Expected error message "${expectedMessage}", but got: "${storedError}"`, + ); + } + return; + } + + throw new Error( + `Expected error message "${expectedMessage}", but no validation error was found. ` + + 'Ensure the validation step actually triggered an error.', + ); + }, +); + +Then('no listing should be created', async function (this: ShareThriftWorld) { + const actor = actorCalled(lastActorName); + + let hasValidationError = false; + try { + const storedError = await actor.answer( + notes<{ lastValidationError?: string }>().get('lastValidationError'), + ); + hasValidationError = !!storedError; + } catch { + // No error stored + } + + let listingId: string | undefined; + try { + listingId = await actor.answer( + notes<{ lastListingId?: string }>().get('lastListingId'), + ); + } catch { + // No listing ID — expected + } + + if (listingId) { + throw new Error( + `Expected no listing to be created, but one was created with id: ${listingId}`, + ); + } + + if (!hasValidationError) { + throw new Error( + 'Expected a validation error to prevent listing creation, but no error was captured. ' + + 'The test may be passing without actually validating the scenario.', + ); + } +}); + +Then( + 'the listing should be in {word} status', + async function (this: ShareThriftWorld, expectedStatus: string) { + const actor = actorCalled(lastActorName); + + await actor.attemptsTo( + Ensure.that(ListingStatus.of(), equals(expectedStatus)), + ); + }, +); + +Then( + 'the listing title should be {string}', + async function (this: ShareThriftWorld, expectedTitle: string) { + const actor = actorCalled(lastActorName); + + await actor.attemptsTo( + Ensure.that(ListingTitle.displayed(), equals(expectedTitle)), + ); + }, +); diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/index.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/index.ts new file mode 100644 index 000000000..e00bf459f --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Listing context step definitions +export {} from './create-listing.steps.ts'; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/listing/tasks/ui/create-listing.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/create-reservation-request-ability.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/index.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/index.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/abilities/index.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/index.ts diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/reservation-request-types.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/reservation-request-types.ts new file mode 100644 index 000000000..6038ad9cd --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/abilities/reservation-request-types.ts @@ -0,0 +1,36 @@ +export interface ReservationRequestNotes { + lastReservationRequestId: string; + lastReservationRequestState: string; + lastReservationRequestStartDate: string; + lastReservationRequestEndDate: string; + lastValidationError: string; + reservationRequestCountForListing: number; +} + +export interface CreateReservationRequestInput { + listingId: string; + reservationPeriodStart: Date; + reservationPeriodEnd: Date; + reserver: { + id: string; + email: string; + firstName: string; + lastName: string; + }; +} + +export interface ReservationRequestResponse { + id: string; + listingId: string; + reserver: { + id: string; + email: string; + firstName: string; + lastName: string; + }; + reservationPeriodStart: Date; + reservationPeriodEnd: Date; + state: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/domain-get-reservation-request-count-for-listing.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/questions/domain-get-reservation-request-count-for-listing.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/questions/domain-get-reservation-request-count-for-listing.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/questions/domain-get-reservation-request-count-for-listing.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts similarity index 88% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts index 6e0580110..b25e221aa 100644 --- a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts @@ -7,29 +7,16 @@ import { } from '../../../shared/support/domain-test-helpers.ts'; import type { ShareThriftWorld } from '../../../world.ts'; import type { ListingDetails } from '../../listing/abilities/listing-types.ts'; -import { CreateListing as ApiCreateListing } from '../../listing/tasks/api/create-listing.ts'; import { CreateListing as UICreateListing } from '../../listing/tasks/ui/create-listing.ts'; import type { CreateReservationRequestInput, ReservationRequestNotes, } from '../abilities/reservation-request-types.ts'; import { DomainGetReservationRequestCountForListing } from '../questions/domain-get-reservation-request-count-for-listing.ts'; -import { GetReservationRequestCountForListing } from '../questions/get-reservation-request-count-for-listing.ts'; -import { CreateReservationRequest as ApiCreateReservationRequest } from '../tasks/api/create-reservation-request.ts'; import { CreateReservationRequest as UICreateReservationRequest } from '../tasks/ui/create-reservation-request.ts'; let lastActorName = 'Alice'; -function getCreateListingTask(level: string) { - if (level === 'api') return ApiCreateListing; - return UICreateListing; -} - -function getCreateReservationRequestTask(level: string) { - if (level === 'api') return ApiCreateReservationRequest; - return UICreateReservationRequest; -} - function parseDateInput(input: string): Date { if (input.startsWith('+')) { const days = Number.parseInt(input.substring(1), 10); @@ -71,10 +58,8 @@ Given( const actor = actorCalled(actorName); const details = dataTable.rowsHash(); - const CreateListing = getCreateListingTask(this.level); - await actor.attemptsTo( - CreateListing.with(details as unknown as ListingDetails), + UICreateListing.with(details as unknown as ListingDetails), ); }, ); @@ -91,16 +76,12 @@ When( const actor = actorCalled(reserver); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask( - this.level, - ); - const listingId = await getListingIdFromOwner(owner); const startDate = data.reservationPeriodStart; const endDate = data.reservationPeriodEnd; await actor.attemptsTo( - CreateReservationRequest.with({ + UICreateReservationRequest.with({ listingId, reservationPeriodStart: startDate ? parseDateInput(String(startDate)) @@ -125,10 +106,6 @@ When( const actor = actorCalled(actorName); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask( - this.level, - ); - await actor.attemptsTo( notes().set( 'lastReservationRequestId', @@ -163,7 +140,9 @@ When( } await actor.attemptsTo( - CreateReservationRequest.with(input as CreateReservationRequestInput), + UICreateReservationRequest.with( + input as CreateReservationRequestInput, + ), ); } catch (error) { const errorMessage = @@ -372,9 +351,7 @@ Then( const actor = actorCalled(lastActorName); const listingId = await getListingIdFromOwner('Bob'); const countQuestion = - this.level === 'ui' - ? DomainGetReservationRequestCountForListing.forListing(listingId) - : GetReservationRequestCountForListing.forListing(listingId); + DomainGetReservationRequestCountForListing.forListing(listingId); await actor.attemptsTo(Ensure.that(countQuestion, equals(1))); }, @@ -392,16 +369,12 @@ Given( const actor = actorCalled(reserver); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask( - this.level, - ); - const listingId = await getListingIdFromOwner(owner); const startDate = data.reservationPeriodStart; const endDate = data.reservationPeriodEnd; await actor.attemptsTo( - CreateReservationRequest.with({ + UICreateReservationRequest.with({ listingId, reservationPeriodStart: startDate ? parseDateInput(String(startDate)) @@ -426,10 +399,6 @@ When( const actor = actorCalled(actorName); const data = dataTable.rowsHash(); - const CreateReservationRequest = getCreateReservationRequestTask( - this.level, - ); - await actor.attemptsTo( notes<{ lastValidationError?: string }>().set( 'lastValidationError', @@ -443,7 +412,7 @@ When( const endDate = data.reservationPeriodEnd; await actor.attemptsTo( - CreateReservationRequest.with({ + UICreateReservationRequest.with({ listingId, reservationPeriodStart: startDate ? parseDateInput(String(startDate)) diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/index.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/index.ts new file mode 100644 index 000000000..49da2f45e --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Reservation Request context step definitions +export {} from './create-reservation-request.steps.ts'; diff --git a/packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts rename to packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/cast.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/cast.ts new file mode 100644 index 000000000..29455c4a4 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/cast.ts @@ -0,0 +1,13 @@ +import { type Actor, type Cast, Notepad, TakeNotes } from '@serenity-js/core'; +import { listingAbilities } from '../../contexts/listing/abilities/index.ts'; +import { reservationRequestAbilities } from '../../contexts/reservation-request/abilities/index.ts'; + +export class ShareThriftUiCast implements Cast { + prepare(actor: Actor): Actor { + return actor.whoCan( + TakeNotes.using(Notepad.empty()), + ...listingAbilities.create(), + ...reservationRequestAbilities.create(), + ); + } +} diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/domain-test-helpers.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/domain-test-helpers.ts new file mode 100644 index 000000000..67a9823e4 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/domain-test-helpers.ts @@ -0,0 +1,163 @@ +import type { Domain } from '@sthrift/domain'; + +export const ONE_DAY_MS = 86_400_000; +export const DEFAULT_SHARING_PERIOD_DAYS = 30; + +// Resolve Gherkin pronoun references to actor names +export function resolveActorName(actorName: string, defaultName = 'Alice'): string { + return /^(she|he|they)$/i.test(actorName) ? defaultName : actorName; +} + +type ItemListingProps = Domain.Contexts.Listing.ItemListing.ItemListingProps; +type ItemListingEntityReference = Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; +type ReservationRequestProps = Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestProps; +type UserEntityReference = Domain.Contexts.User.UserEntityReference; +type Passport = Domain.Passport; + +export function makeTestPassport(): Passport { + const alwaysAllow = { determineIf: (fn: (p: Record) => boolean) => fn(new Proxy({}, { get: () => true })) }; + return { + listing: { forItemListing: () => alwaysAllow }, + user: { + forPersonalUser: () => alwaysAllow, + forAdminUser: () => alwaysAllow, + forAdminRole: () => alwaysAllow, + }, + conversation: { forConversation: () => alwaysAllow }, + reservationRequest: { forReservationRequest: () => alwaysAllow }, + accountPlan: { forAccountPlan: () => alwaysAllow }, + appealRequest: { forAppealRequest: () => alwaysAllow }, + } as unknown as Passport; +} + +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, + }; +} + +export function makeSharerUser(overrides: Partial<{ id: string; email: string; firstName: string; lastName: string }> = {}): UserEntityReference { + return { + id: overrides.id ?? 'test-sharer-1', + userType: 'personal-user', + isBlocked: false, + hasCompletedOnboarding: true, + account: { + accountType: 'standard', + email: overrides.email ?? 'sharer@test.com', + username: overrides.firstName?.toLowerCase() ?? 'sharer', + profile: { + firstName: overrides.firstName ?? 'Sharer', + lastName: overrides.lastName ?? 'User', + aboutMe: '', + location: { + address1: '123 Test St', + address2: null, + city: 'Seattle', + state: 'WA', + country: 'US', + zipCode: '98101', + }, + billing: { + cybersourceCustomerId: null, + subscription: { + status: 'inactive', + planCode: 'free', + startDate: new Date('2020-01-01'), + subscriptionId: null, + }, + transactions: { + items: [], + getNewItem: () => ({}), + addItem: () => { /* no-op */ }, + removeItem: () => { /* no-op */ }, + removeAll: () => { /* no-op */ }, + }, + }, + }, + }, + schemaVersion: '1.0.0', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + } as unknown as UserEntityReference; +} + +export function makeItemListingProps(overrides: Partial = {}): ItemListingProps { + const sharer = makeSharerUser(); + return { + id: overrides.id ?? `listing-${Date.now()}`, + sharer, + loadSharer: async () => sharer, + title: 'Default Title', + description: 'Default Description', + category: 'Electronics', + location: 'Seattle, WA', + sharingPeriodStart: new Date(Date.now() + 86_400_000), + sharingPeriodEnd: new Date(Date.now() + 86_400_000 * 30), + state: 'Active', + images: [], + sharingHistory: [], + reports: 0, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0.0', + listingType: 'item', + ...overrides, + } as ItemListingProps; +} + +export function makeListingReference(overrides: Partial<{ id: string; state: string }> = {}): ItemListingEntityReference { + return { + id: overrides.id ?? `listing-${Date.now()}`, + sharer: makeSharerUser(), + title: 'Test Listing', + description: 'Test', + category: 'Electronics', + location: 'Seattle', + sharingPeriodStart: new Date(Date.now() + 3_600_000), + sharingPeriodEnd: new Date(Date.now() + 7_200_000), + state: overrides.state ?? 'Active', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + schemaVersion: '1', + listingType: 'item', + } as ItemListingEntityReference; +} + +export function makeReservationRequestProps(overrides: Partial = {}): ReservationRequestProps { + const listing = makeListingReference(); + const reserver = makeSharerUser({ id: 'reserver-1' }); + const tomorrow = new Date(Date.now() + 86_400_000); + const nextMonth = new Date(Date.now() + 86_400_000 * 30); + return { + id: overrides.id ?? `rr-${Date.now()}`, + state: 'Requested', + reservationPeriodStart: tomorrow, + reservationPeriodEnd: nextMonth, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1', + listing, + loadListing: async () => listing, + reserver, + loadReserver: async () => reserver, + closeRequestedBySharer: false, + closeRequestedByReserver: false, + ...overrides, + } as ReservationRequestProps; +} diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/formatters/agent-formatter.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/formatters/agent-formatter.ts new file mode 100644 index 000000000..3c9c212f6 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/formatters/agent-formatter.ts @@ -0,0 +1,135 @@ +import { Formatter, formatterHelpers, type IFormatterOptions } from '@cucumber/cucumber'; +import type { Envelope, TestCaseFinished, TestRunFinished, TestRunStarted, Timestamp } from '@cucumber/messages'; + +type ParsedTestSteps = ReturnType< + typeof formatterHelpers.parseTestCaseAttempt +>['testSteps']; + +const STATUS_ICONS: Record = { + PASSED: 'PASS', + FAILED: 'FAIL', + SKIPPED: 'SKIP', + PENDING: 'PEND', + UNDEFINED: 'UNDEF', + AMBIGUOUS: 'AMBIG', + UNKNOWN: '?', +}; + +function timestampToMs(ts: Timestamp): number { + return (ts.seconds ?? 0) * 1000 + Math.round((ts.nanos ?? 0) / 1_000_000); +} +export default class AgentFormatter extends Formatter { + 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 = {}; + + constructor(options: IFormatterOptions) { + super(options); + options.eventBroadcaster.on( + 'envelope', + (envelope: Envelope) => this.parseEnvelope(envelope), + ); + } + + private parseEnvelope(envelope: Envelope): void { + if (envelope.testRunStarted) { + this.testRunStarted = envelope.testRunStarted; + } else if (envelope.testCaseFinished) { + this.onTestCaseFinished(envelope.testCaseFinished); + } else if (envelope.testRunFinished) { + this.onTestRunFinished(envelope.testRunFinished); + } + } + + private onTestCaseFinished(testCaseFinished: TestCaseFinished): void { + const attempt = this.eventDataCollector.getTestCaseAttempt( + testCaseFinished.testCaseStartedId, + ); + const statusKey = String(attempt.worstTestStepResult.status); + + this.scenarioCount++; + this.statusCounts[statusKey] = (this.statusCounts[statusKey] ?? 0) + 1; + + const parsed = formatterHelpers.parseTestCaseAttempt({ + testCaseAttempt: attempt, + snippetBuilder: this.snippetBuilder, + supportCodeLibrary: this.supportCodeLibrary, + }); + + const icon = STATUS_ICONS[statusKey] ?? '?'; + const { name, sourceLocation } = parsed.testCase; + const loc = 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.logFailedSteps(parsed.testSteps); + } + // Passing scenarios are not logged individually to save tokens. + } + + private logFailedSteps(testSteps: ParsedTestSteps): void { + for (const step of testSteps) { + const stepStatus = String(step.result.status); + if (stepStatus === 'PASSED' || stepStatus === 'SKIPPED') continue; + + const stepIcon = STATUS_ICONS[stepStatus] ?? '?'; + const stepText = step.text ?? step.keyword?.trim() ?? '(hook)'; + this.log(` [${stepIcon}] ${stepText}\n`); + + if (step.result.message) { + const lines = step.result.message.split('\n'); + const truncated = lines.slice(0, 15); + for (const line of truncated) { + this.log(` ${line}\n`); + } + if (lines.length > 15) { + this.log(` ... (${lines.length - 15} more lines)\n`); + } + } + + if (step.snippet) { + this.log(` snippet: ${step.snippet}\n`); + } + } + } + + 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}`); + } + this.log(`Scenarios: ${this.scenarioCount} (${parts.join(', ')})\n`); + + if (this.testRunStarted?.timestamp && testRunFinished.timestamp) { + const ms = + timestampToMs(testRunFinished.timestamp) - + timestampToMs(this.testRunStarted.timestamp); + this.log(`Duration: ${ms}ms\n`); + } + + if (this.issueCount === 0) { + this.log('All scenarios passed.\n'); + } else { + this.log(`Issues: ${this.issueCount}\n`); + } + } +} diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts new file mode 100644 index 000000000..d8059ce50 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts @@ -0,0 +1,26 @@ +import type { IWorld } from '@cucumber/cucumber'; +import { After, Before, setDefaultTimeout } from '@cucumber/cucumber'; +import { isAgent } from 'std-env'; +import { type ShareThriftUiWorld } from '../../world.ts'; + +let printedSuiteHeader = false; + +setDefaultTimeout(120_000); + +Before(async function (this: IWorld) { + const world = this as IWorld & ShareThriftUiWorld; + + if (!printedSuiteHeader && !isAgent) { + printedSuiteHeader = true; + console.log('\nUI acceptance tests'); + console.log(' - Listing context'); + console.log(' - Reservation request context\n'); + } + + await world.init(); +}); + +After(async function (this: IWorld) { + const world = this as IWorld & ShareThriftUiWorld; + await world.cleanup(); +}); diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/ui/asset-loader-hooks.mjs rename to packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/ui/jsdom-setup.ts rename to packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/ui/react-render.tsx rename to packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/ui/register-asset-loader.ts rename to packages/sthrift-verification/acceptance-ui/src/shared/support/ui/register-asset-loader.ts diff --git a/packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts similarity index 100% rename from packages/sthrift-verification/acceptance-tests/src/shared/support/ui/setup-jsdom.ts rename to packages/sthrift-verification/acceptance-ui/src/shared/support/ui/setup-jsdom.ts diff --git a/packages/sthrift-verification/acceptance-ui/src/step-definitions/index.ts b/packages/sthrift-verification/acceptance-ui/src/step-definitions/index.ts new file mode 100644 index 000000000..233e7f87b --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/step-definitions/index.ts @@ -0,0 +1,7 @@ +/** + * Central loader for step definitions. + * Cucumber imports this file, which then loads all context-specific step definitions. + */ + +export * from '../contexts/listing/step-definitions/index.ts'; +export * from '../contexts/reservation-request/step-definitions/index.ts'; diff --git a/packages/sthrift-verification/acceptance-ui/src/world.ts b/packages/sthrift-verification/acceptance-ui/src/world.ts new file mode 100644 index 000000000..dd6b4b76d --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/src/world.ts @@ -0,0 +1,32 @@ +import { + setWorldConstructor, + World, + type IWorldOptions, +} from '@cucumber/cucumber'; +import { engage } from '@serenity-js/core'; +import { + clearMockListings, + clearMockReservationRequests, +} from '@sthrift-verification/test-support/test-data'; +import './shared/support/hooks.ts'; +import { ShareThriftUiCast } from './shared/support/cast.ts'; + +export class ShareThriftUiWorld extends World { + constructor(options: IWorldOptions) { + super(options); + } + + async init(): Promise { + clearMockReservationRequests(); + clearMockListings(); + engage(new ShareThriftUiCast()); + } + + async cleanup(): Promise { + // No cleanup needed per scenario. + } +} + +export { ShareThriftUiWorld as ShareThriftWorld }; + +setWorldConstructor(ShareThriftUiWorld); diff --git a/packages/sthrift-verification/acceptance-ui/tsconfig.json b/packages/sthrift-verification/acceptance-ui/tsconfig.json new file mode 100644 index 000000000..1fdb1929d --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@cellix/typescript-config/node.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "erasableSyntaxOnly": false, + "jsx": "react-jsx" + }, + "include": [ + "src/**/*", + "features/**/*" + ], + "exclude": [ + "node_modules", + "**/node_modules", + "dist", + "coverage", + "reports", + "target" + ] +} diff --git a/packages/sthrift-verification/acceptance-ui/turbo.json b/packages/sthrift-verification/acceptance-ui/turbo.json new file mode 100644 index 000000000..1682c93b5 --- /dev/null +++ b/packages/sthrift-verification/acceptance-ui/turbo.json @@ -0,0 +1,15 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["^build"], + "inputs": ["src/**", "cucumber.js"], + "outputs": ["reports/**"] + }, + "test:coverage": { + "dependsOn": ["^build"], + "inputs": ["src/**", "cucumber.js", ".c8rc.json"], + "outputs": ["reports/**", "coverage-c8/**", ".c8-output/**"] + } + } +} diff --git a/packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep b/packages/sthrift-verification/test-support/src/scenarios/steps/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1089b9ea..a0ad23e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1134,7 +1134,7 @@ importers: specifier: ^5.8.3 version: 5.8.3 - packages/sthrift-verification/acceptance-tests: + packages/sthrift-verification/acceptance-api: dependencies: '@cucumber/cucumber': specifier: ^12.7.0 @@ -1161,15 +1161,9 @@ importers: specifier: ^4.0.0 version: 4.0.0 devDependencies: - '@ant-design/icons': - specifier: ^6.1.0 - version: 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@apollo/server': specifier: '>=5.5.0' version: 5.5.0(graphql@16.13.1) - '@apps/ui-sharethrift': - specifier: workspace:* - version: link:../../../apps/ui-sharethrift '@cellix/service-messaging-base': specifier: workspace:* version: link:../../cellix/service-messaging-base @@ -1206,45 +1200,100 @@ importers: '@sthrift/persistence': specifier: workspace:* version: link:../../sthrift/persistence - '@sthrift/ui-components': - specifier: workspace:* - version: link:../../sthrift/ui-components - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/graphql-depth-limit': specifier: ^1.1.6 version: 1.1.6 - '@types/jsdom': - specifier: ^21.1.7 - version: 21.1.7 '@types/node': specifier: ^24.10.7 version: 24.12.0 - antd: - specifier: ^5.27.0 - version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) c8: specifier: ^11.0.0 version: 11.0.0 - dayjs: - specifier: ^1.11.0 - version: 1.11.20 graphql-depth-limit: specifier: ^1.1.0 version: 1.1.0(graphql@16.13.1) graphql-middleware: specifier: ^6.1.35 version: 6.1.35(graphql@16.13.1) - jsdom: - specifier: ^26.1.0 - version: 26.1.0 mongodb: specifier: ^6.15.0 version: 6.21.0 mongodb-memory-server: specifier: ^10.2.0 version: 10.4.3 + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + tsx: + specifier: ^4.20.3 + version: 4.21.0 + typescript: + specifier: ^5.4.5 + version: 5.8.3 + + packages/sthrift-verification/acceptance-ui: + dependencies: + '@cucumber/cucumber': + specifier: ^12.7.0 + version: 12.7.0 + '@serenity-js/assertions': + specifier: ^3.37.2 + version: 3.41.2 + '@serenity-js/console-reporter': + specifier: ^3.37.2 + version: 3.41.2 + '@serenity-js/core': + specifier: ^3.37.2 + version: 3.41.2 + '@serenity-js/cucumber': + specifier: ^3.37.2 + version: 3.41.2(@cucumber/cucumber@12.7.0) + '@serenity-js/serenity-bdd': + specifier: ^3.37.2 + version: 3.41.2 + std-env: + specifier: ^4.0.0 + version: 4.0.0 + devDependencies: + '@ant-design/icons': + specifier: ^6.1.0 + version: 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@apps/ui-sharethrift': + specifier: workspace:* + version: link:../../../apps/ui-sharethrift + '@cellix/typescript-config': + specifier: workspace:* + version: link:../../cellix/typescript-config + '@cucumber/messages': + specifier: ^32.2.0 + version: 32.2.0 + '@sthrift-verification/test-support': + specifier: workspace:* + version: link:../test-support + '@sthrift/domain': + specifier: workspace:* + version: link:../../sthrift/domain + '@sthrift/ui-components': + specifier: workspace:* + version: link:../../sthrift/ui-components + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/node': + specifier: ^24.10.7 + version: 24.12.0 + antd: + specifier: ^5.27.0 + version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + c8: + specifier: ^11.0.0 + version: 11.0.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 react: specifier: ^19.1.0 version: 19.2.4 diff --git a/sonar-project.properties b/sonar-project.properties index 531d879ae..a0253cd43 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -46,7 +46,8 @@ packages/sthrift/rest/src,\ packages/sthrift/ui-components/src,\ packages/sthrift-verification/arch-unit-tests/src, -packages/sthrift-verification/acceptance-tests/src, +packages/sthrift-verification/acceptance-api/src, +packages/sthrift-verification/acceptance-ui/src, packages/sthrift-verification/e2e-tests/src sonar.tests=apps/api/src,\ @@ -90,7 +91,8 @@ packages/sthrift/rest/src,\ packages/sthrift/ui-components/src,\ packages/sthrift-verification/arch-unit-tests/src, -packages/sthrift-verification/acceptance-tests/src, +packages/sthrift-verification/acceptance-api/src, +packages/sthrift-verification/acceptance-ui/src, packages/sthrift-verification/e2e-tests/src # Test inclusions @@ -102,7 +104,7 @@ sonar.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.t # Coverage exclusions # Standard exclusions: config, test, generated files # Infrastructure exclusions: mongoose models, service config, graphql schema-builder (matching Cellix pattern) -sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/generated.ts,**/generated.tsx,**/*.d.ts,dist/**,apps/docs/src/test/**,build-pipeline/scripts/**,packages/sthrift/domain/tests/**,apps/server-messaging-mock/**,apps/server-mongodb-memory-mock/**,apps/server-oauth2-mock/**,apps/server-payment-mock/**,packages/cellix/server-messaging-seedwork/**,packages/cellix/server-mongodb-memory-seedwork/**,packages/cellix/server-oauth2-seedwork/**,packages/cellix/server-payment-seedwork/**,packages/cellix/service-messaging-mock/**,packages/cellix/service-payment-mock/**,packages/sthrift/data-sources-mongoose-models/**,packages/sthrift/graphql/src/schema/builder/schema-builder.ts,apps/api/src/service-config/**,packages/cellix/arch-unit-tests/**,packages/sthrift-verification/arch-unit-tests/**,packages/sthrift-verification/acceptance-tests/**,packages/sthrift-verification/e2e-tests/**,apps/ui-sharethrift/**,packages/cellix/ui-core/**,packages/cellix/service-token-validation/src/*,packages/cellix/service-sendgrid/*,packages/cellix/service-payment-cybersource/src/*,packages/cellix/service-messaging-twilio/src/index.ts,packages/cellix/service-blob-storage/src/index.ts +sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/generated.ts,**/generated.tsx,**/*.d.ts,dist/**,apps/docs/src/test/**,build-pipeline/scripts/**,packages/sthrift/domain/tests/**,apps/server-messaging-mock/**,apps/server-mongodb-memory-mock/**,apps/server-oauth2-mock/**,apps/server-payment-mock/**,packages/cellix/server-messaging-seedwork/**,packages/cellix/server-mongodb-memory-seedwork/**,packages/cellix/server-oauth2-seedwork/**,packages/cellix/server-payment-seedwork/**,packages/cellix/service-messaging-mock/**,packages/cellix/service-payment-mock/**,packages/sthrift/data-sources-mongoose-models/**,packages/sthrift/graphql/src/schema/builder/schema-builder.ts,apps/api/src/service-config/**,packages/cellix/arch-unit-tests/**,packages/sthrift-verification/arch-unit-tests/**,packages/sthrift-verification/acceptance-api/**,packages/sthrift-verification/acceptance-ui/**,packages/sthrift-verification/e2e-tests/**,apps/ui-sharethrift/**,packages/cellix/ui-core/**,packages/cellix/service-token-validation/src/*,packages/cellix/service-sendgrid/*,packages/cellix/service-payment-cybersource/src/*,packages/cellix/service-messaging-twilio/src/index.ts,packages/cellix/service-blob-storage/src/index.ts # CPD (code duplication) exclusions sonar.cpd.exclusions=**/*.test.ts,**/generated.tsx @@ -112,4 +114,4 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.typescript.lcov.reportPaths=coverage/lcov.info # SCM -sonar.scm.provider=git \ No newline at end of file +sonar.scm.provider=git From 53659113952a31a1ecb8d09c5a360cfd6f9b0d21 Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Mon, 6 Apr 2026 12:17:04 -0400 Subject: [PATCH 6/7] test fixes, portless fix and additional port removal, page interfaces per package made --- package.json | 4 +- .../listing/tasks/ui/create-listing.ts | 9 ++- .../tasks/ui/create-reservation-request.ts | 9 ++- .../e2e-tests/package.json | 2 +- .../listing/questions/listing-status.ts | 9 ++- .../listing/questions/listing-title.ts | 9 ++- .../contexts/listing/tasks/create-listing.ts | 37 ++++++------ .../tasks/create-reservation-request.ts | 11 ++-- .../src/shared/support/oauth2-login.ts | 6 +- .../shared/support/servers/portless-server.ts | 2 +- .../support/servers/test-environment.ts | 5 +- .../support/servers/test-oauth2-server.ts | 13 ---- .../shared/support/shared-infrastructure.ts | 60 ++++++++++--------- .../test-support/src/pages/index.ts | 10 ++++ .../src/pages/page-interfaces/index.ts | 16 +++++ .../page-interfaces/listing.page-interface.ts | 23 +++++++ .../page-interfaces/login.page-interface.ts | 8 +++ .../onboarding.page-interface.ts | 5 ++ .../reservation.page-interface.ts | 22 +++++++ 19 files changed, 182 insertions(+), 78 deletions(-) create mode 100644 packages/sthrift-verification/test-support/src/pages/page-interfaces/index.ts create mode 100644 packages/sthrift-verification/test-support/src/pages/page-interfaces/listing.page-interface.ts create mode 100644 packages/sthrift-verification/test-support/src/pages/page-interfaces/login.page-interface.ts create mode 100644 packages/sthrift-verification/test-support/src/pages/page-interfaces/onboarding.page-interface.ts create mode 100644 packages/sthrift-verification/test-support/src/pages/page-interfaces/reservation.page-interface.ts diff --git a/package.json b/package.json index 0e670108b..176461cc4 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "dev": "pnpm run build && pnpm run proxy:stop && pnpm run proxy:start && turbo run azurite gen:watch dev --parallel", "start": "turbo run build && concurrently pnpm:start:* --kill-others-on-fail --workspace=@app/api", "format": "turbo run format", - "proxy:stop": "portless proxy stop || true", - "proxy:start": "portless proxy start", + "proxy:stop": "PORTLESS_STATE_DIR=${HOME}/.portless portless proxy stop || true", + "proxy:start": "PORTLESS_STATE_DIR=${HOME}/.portless portless proxy start", "gen": "graphql-codegen --config codegen.yml", "gen:watch": "graphql-codegen --config codegen.yml --watch", "tsbuild": "tsc --build", diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts index 083507681..d8d63b2ee 100644 --- a/packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/listing/tasks/ui/create-listing.ts @@ -5,7 +5,10 @@ import * as React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ListingForm } from '@sthrift/ui-components'; import { CreateListing as CreateListingComponent } from '@apps/ui-sharethrift/src/components/layouts/app/pages/create-listing/components/create-listing.tsx'; -import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { + ListingPage, + type UiListingPage, +} from '@sthrift-verification/test-support/pages'; import { JsdomPageAdapter } from '@sthrift-verification/test-support/pages/jsdom'; import { CreateListingAbility } from '../../abilities/create-listing-ability.ts'; import type { @@ -96,7 +99,9 @@ export class CreateListing extends Task { ); // Use shared page object for form interactions - const page = new ListingPage(new JsdomPageAdapter(container)); + const page: UiListingPage = new ListingPage( + new JsdomPageAdapter(container), + ); await act(async () => { await page.fillForm({ diff --git a/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts index 58cb6e00f..2a5e82b68 100644 --- a/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts +++ b/packages/sthrift-verification/acceptance-ui/src/contexts/reservation-request/tasks/ui/create-reservation-request.ts @@ -3,7 +3,10 @@ import { type Actor, notes, Task } from '@serenity-js/core'; import { render, cleanup, act } from '@testing-library/react'; import * as React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { ReservationPage } from '@sthrift-verification/test-support/pages'; +import { + ReservationPage, + type UiReservationPage, +} from '@sthrift-verification/test-support/pages'; import { JsdomPageAdapter } from '@sthrift-verification/test-support/pages/jsdom'; import { ReservationCard } from '@apps/ui-sharethrift/src/components/layouts/app/pages/my-reservations/components/reservation-card.tsx'; import { ReservationRequestForm } from '@apps/ui-sharethrift/src/components/layouts/app/pages/view-listing/components/reservation-request-form.tsx'; @@ -103,7 +106,9 @@ export class CreateReservationRequest extends Task { ); // Use shared page object for form interactions - const page = new ReservationPage(new JsdomPageAdapter(container)); + const page: UiReservationPage = new ReservationPage( + new JsdomPageAdapter(container), + ); await act(async () => { await page.openDatePicker(); diff --git a/packages/sthrift-verification/e2e-tests/package.json b/packages/sthrift-verification/e2e-tests/package.json index 23bf9f478..4713ccd62 100644 --- a/packages/sthrift-verification/e2e-tests/package.json +++ b/packages/sthrift-verification/e2e-tests/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "test:e2e": "NODE_EXTRA_CA_CERTS=/tmp/portless/ca.pem LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --format json:./reports/cucumber-report-e2e.json", + "test:e2e": "NODE_EXTRA_CA_CERTS=${HOME}/.portless/ca.pem LOG_LEVEL=warn NODE_OPTIONS='--import tsx/esm' cucumber-js --format json:./reports/cucumber-report-e2e.json", "playwright:install": "playwright install chromium", "clean": "rimraf dist reports target" }, diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts index 360097f50..6b8300945 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-status.ts @@ -1,6 +1,9 @@ import { Question, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { + type E2EListingPage, + ListingPage, +} from '@sthrift-verification/test-support/pages'; import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; export class ListingStatus extends Question> { @@ -45,7 +48,9 @@ export class ListingStatus extends Question> { try { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); + const listingPage: E2EListingPage = new ListingPage( + new PlaywrightPageAdapter(page), + ); const statusTag = await listingPage.statusTagInRow(listingTitle); if (!statusTag) { return undefined; diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts index df3697241..4789201f7 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/questions/listing-title.ts @@ -1,6 +1,9 @@ import { Question, type AnswersQuestions, type UsesAbilities, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { + type E2EListingPage, + ListingPage, +} from '@sthrift-verification/test-support/pages'; import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; export class ListingTitle extends Question> { @@ -42,7 +45,9 @@ export class ListingTitle extends Question> { try { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); + const listingPage: E2EListingPage = new ListingPage( + new PlaywrightPageAdapter(page), + ); const titleCell = listingPage.listingTitleCell(listingTitle); await titleCell.waitFor({ state: 'visible', timeout: 3_000 }); return (await titleCell.textContent())?.trim() || undefined; diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts index 2c2bb6668..b335437e1 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url'; import { type Actor, Task, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; -import { ListingPage } from '@sthrift-verification/test-support/pages'; +import { + type E2EListingPage, + ListingPage, +} from '@sthrift-verification/test-support/pages'; import { PlaywrightPageAdapter } from '@sthrift-verification/test-support/pages/playwright'; import type { ListingDetails, ListingNotes } from '../types.ts'; @@ -24,27 +27,20 @@ export class CreateListing extends Task { async performAs(actor: Actor): Promise { const { page } = BrowseTheWeb.withActor(actor); - const listingPage = new ListingPage(new PlaywrightPageAdapter(page)); + const listingPage: E2EListingPage = new ListingPage( + new PlaywrightPageAdapter(page), + ); await page.goto('/create-listing', { waitUntil: 'domcontentloaded' }); await page.waitForURL('**/create-listing', { timeout: 15_000, waitUntil: 'commit' }); await this.ensureCreateListingFormReady(page, listingPage); - if (this.details.title) { - await listingPage.fillTitle(this.details.title); - } - - if (this.details.description) { - await listingPage.fillDescription(this.details.description); - } - - if (this.details.category) { - await listingPage.selectCategory(this.details.category); - } - - if (this.details.location) { - await listingPage.fillLocation(this.details.location); - } + await listingPage.fillForm({ + title: this.details.title, + description: this.details.description, + category: this.details.category, + location: this.details.location, + }); // Fill sharing period using the Ant Design date picker const rangePickerVisible = await page.locator('.ant-picker-range').isVisible(); @@ -154,7 +150,7 @@ export class CreateListing extends Task { // Verify actual page navigation occurred await page.waitForURL('**/my-listings**', { timeout: 10_000 }); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Read listing title from the table DOM const listingTitleCell = listingPage.listingTitleCell(this.details.title); @@ -196,7 +192,10 @@ export class CreateListing extends Task { ); } - private async ensureCreateListingFormReady(page: BrowseTheWeb['page'], listingPage: ListingPage): Promise { + private async ensureCreateListingFormReady( + page: BrowseTheWeb['page'], + listingPage: E2EListingPage, + ): Promise { try { await listingPage.titleInput.waitFor({ state: 'visible', timeout: 15_000 }); } catch { diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts index 484b092ec..7c2b33250 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/tasks/create-reservation-request.ts @@ -1,6 +1,7 @@ import { Task, type Actor, notes } from '@serenity-js/core'; import { BrowseTheWeb } from '../../../shared/abilities/browse-the-web.ts'; import { + type E2EReservationPage, ReservationPage, formatDate, } from '@sthrift-verification/test-support/pages'; @@ -18,10 +19,11 @@ export class CreateReservationRequest extends Task { async performAs(actor: Actor): Promise { const { page } = BrowseTheWeb.withActor(actor); - const reservationPage = new ReservationPage(new PlaywrightPageAdapter(page)); + const reservationPage: E2EReservationPage = new ReservationPage( + new PlaywrightPageAdapter(page), + ); - await page.goto(`/listing/${this.input.listingId}`); - await page.waitForLoadState('networkidle'); + await page.goto(`/listing/${this.input.listingId}`, { waitUntil: 'domcontentloaded' }); // Wait for all GraphQL queries to resolve (skeleton disappears) await reservationPage.skeleton.waitFor({ state: 'hidden', timeout: 15_000 }); @@ -74,7 +76,8 @@ export class CreateReservationRequest extends Task { await endCell.click(); const dateSelectionError = await reservationPage.overlapErrorMessage - .textContent() + .waitFor({ state: 'visible', timeout: 500 }) + .then(() => reservationPage.overlapErrorMessage.textContent()) .catch(() => null); if (dateSelectionError) { throw new Error('Reservation period overlaps with existing active reservation requests'); diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts index 4ae12854d..be64e69a4 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Page } from '@playwright/test'; import { + type E2ELoginPage, + type E2EOnboardingPage, LoginPage, OnboardingPage, } from '@sthrift-verification/test-support/pages'; @@ -34,7 +36,7 @@ function loadTestCredentials(): { username: string; password: string } { export async function performOAuth2Login(page: Page): Promise { const { username, password } = loadTestCredentials(); const pageAdapter = new PlaywrightPageAdapter(page); - const loginPage = new LoginPage(pageAdapter); + const loginPage: E2ELoginPage = new LoginPage(pageAdapter); await loginPage.goto(); await loginPage.login(username, password); @@ -42,7 +44,7 @@ export async function performOAuth2Login(page: Page): Promise { // Complete post-login onboarding if redirected to signup if (pageAdapter.url().includes('/signup')) { - const onboardingPage = new OnboardingPage(pageAdapter); + const onboardingPage: E2EOnboardingPage = new OnboardingPage(pageAdapter); await onboardingPage.completeOnboarding(); } } diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts index df15c4da3..ad0939d24 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts @@ -32,7 +32,7 @@ export abstract class PortlessServer { this.process = spawn(getPortlessPath(), this.spawnArgs, { cwd: this.cwd, - env: { ...process.env, ...this.extraEnv }, + env: { ...process.env, PORTLESS_STATE_DIR: `${process.env['HOME']}/.portless`, ...this.extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }); this.startedByUs = true; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts index 52ff759cc..92436b38b 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts @@ -7,10 +7,13 @@ let mongoConnectionString: string | undefined; export function initTestEnvironment() { if (proxyInitialized) return; - // Ensure the global portless proxy is running (HTTPS on port 443 by default) + // Ensure the global portless proxy is running (HTTPS on port 443, the 0.9.x default) + // Force PORTLESS_STATE_DIR so the proxy uses ~/.portless/ (not /tmp/portless/) + // to keep the CA cert path consistent with NODE_EXTRA_CA_CERTS. execFileSync(getPortlessPath(), ['proxy', 'start'], { timeout: 15_000, stdio: 'pipe', + env: { ...process.env, PORTLESS_STATE_DIR: `${process.env['HOME']}/.portless` }, }); proxyInitialized = true; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts index 78878faa8..3c52b8b90 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-oauth2-server.ts @@ -41,18 +41,6 @@ export class TestOAuth2Server extends PortlessServer { }; } - override async isAlreadyRunning(): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3_000); - const res = await fetch(this.probeUrl, { signal: controller.signal }); - clearTimeout(timeout); - return res.ok; - } catch { - return false; - } - } - getUrl(): string { return buildUrl('mock-auth.sharethrift.localhost'); } @@ -84,4 +72,3 @@ export class TestOAuth2Server extends PortlessServer { return data.access_token; } } - diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index 1baf73741..c369dd5ab 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -61,39 +61,45 @@ export async function ensureE2EServers(): Promise { } async function initLocalE2E(): Promise { - await initTestEnvironment(); - - if (!mongoDBServer) { - mongoDBServer = new MongoDBTestServer(); - await mongoDBServer.start(); - setMongoConnectionString(mongoDBServer.getConnectionString()); + initTestEnvironment(); + + // Phase 1: Start MongoDB and OAuth2 in parallel (no interdependency) + mongoDBServer ??= new MongoDBTestServer(); + oauth2Server ??= new TestOAuth2Server({ + testUser: { + email: defaultActor.email, + given_name: defaultActor.givenName, + family_name: defaultActor.familyName, + }, + }); + const mongo = mongoDBServer; + const oauth2 = oauth2Server; + const phase1: Promise[] = []; + if (!mongo.isRunning()) { + phase1.push(mongo.start().then(() => setMongoConnectionString(mongo.getConnectionString()))); } - - if (!oauth2Server) { - oauth2Server = new TestOAuth2Server({ - testUser: { - email: defaultActor.email, - given_name: defaultActor.givenName, - family_name: defaultActor.familyName, - }, - }); - await oauth2Server.start(); + if (!oauth2.isRunning()) { + phase1.push(oauth2.start()); } - - if (!apiServer) { - apiServer = new TestApiServer(); - await apiServer.start(); - apiUrl = apiServer.getUrl(); + if (phase1.length > 0) await Promise.all(phase1); + + // Phase 2: Start API (needs MongoDB conn string), Vite (independent), and generate token (needs OAuth2) in parallel + apiServer ??= new TestApiServer(); + viteServer ??= new TestViteServer(); + const api = apiServer; + const vite = viteServer; + const phase2: Promise[] = []; + if (!api.isRunning()) { + phase2.push(api.start().then(() => { apiUrl = api.getUrl(); })); + } + if (!vite.isRunning()) { + phase2.push(vite.start()); } - if (!accessToken) { - accessToken = await oauth2Server.generateAccessToken(apiSettings.userPortalOidcAudience); + phase2.push(oauth2.generateAccessToken(apiSettings.userPortalOidcAudience).then((token) => { accessToken = token; })); } + if (phase2.length > 0) await Promise.all(phase2); - if (!viteServer) { - viteServer = new TestViteServer(); - await viteServer.start(); - } browserBaseUrl = viteServer.getUrl(); if (!apiUrl) { diff --git a/packages/sthrift-verification/test-support/src/pages/index.ts b/packages/sthrift-verification/test-support/src/pages/index.ts index 734cb748c..16bdbbdc0 100644 --- a/packages/sthrift-verification/test-support/src/pages/index.ts +++ b/packages/sthrift-verification/test-support/src/pages/index.ts @@ -1,6 +1,16 @@ export { LoginPage } from './login.page.ts'; export { OnboardingPage } from './onboarding.page.ts'; export { ListingPage } from './listing.page.ts'; +export type { + E2EListingPage, + UiListingPage, + E2ELoginPage, + UiLoginPage, + E2EOnboardingPage, + UiOnboardingPage, + E2EReservationPage, + UiReservationPage, +} from './page-interfaces/index.ts'; export type { ElementHandle, PageAdapter, diff --git a/packages/sthrift-verification/test-support/src/pages/page-interfaces/index.ts b/packages/sthrift-verification/test-support/src/pages/page-interfaces/index.ts new file mode 100644 index 000000000..e959f698c --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/page-interfaces/index.ts @@ -0,0 +1,16 @@ +export type { + E2EListingPage, + UiListingPage, +} from './listing.page-interface.ts'; +export type { + E2ELoginPage, + UiLoginPage, +} from './login.page-interface.ts'; +export type { + E2EOnboardingPage, + UiOnboardingPage, +} from './onboarding.page-interface.ts'; +export type { + E2EReservationPage, + UiReservationPage, +} from './reservation.page-interface.ts'; diff --git a/packages/sthrift-verification/test-support/src/pages/page-interfaces/listing.page-interface.ts b/packages/sthrift-verification/test-support/src/pages/page-interfaces/listing.page-interface.ts new file mode 100644 index 000000000..4e5534b9e --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/page-interfaces/listing.page-interface.ts @@ -0,0 +1,23 @@ +import type { ListingPage } from '../listing.page.ts'; + +export type UiListingPage = Pick< + ListingPage, + 'fillForm' | 'clickSaveDraft' | 'clickPublish' +>; + +export type E2EListingPage = Pick< + ListingPage, + | 'titleInput' + | 'homeCreateListingButton' + | 'fillForm' + | 'clickPublish' + | 'saveDraftButton' + | 'publishButton' + | 'firstValidationError' + | 'loadingButton' + | 'modal' + | 'viewDraftButton' + | 'viewListingButton' + | 'listingTitleCell' + | 'statusTagInRow' +>; diff --git a/packages/sthrift-verification/test-support/src/pages/page-interfaces/login.page-interface.ts b/packages/sthrift-verification/test-support/src/pages/page-interfaces/login.page-interface.ts new file mode 100644 index 000000000..4968c7813 --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/page-interfaces/login.page-interface.ts @@ -0,0 +1,8 @@ +import type { LoginPage } from '../login.page.ts'; + +export type UiLoginPage = Pick; + +export type E2ELoginPage = Pick< + LoginPage, + 'goto' | 'login' | 'waitForRedirectComplete' +>; diff --git a/packages/sthrift-verification/test-support/src/pages/page-interfaces/onboarding.page-interface.ts b/packages/sthrift-verification/test-support/src/pages/page-interfaces/onboarding.page-interface.ts new file mode 100644 index 000000000..1d7584fb5 --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/page-interfaces/onboarding.page-interface.ts @@ -0,0 +1,5 @@ +import type { OnboardingPage } from '../onboarding.page.ts'; + +export type UiOnboardingPage = Pick; + +export type E2EOnboardingPage = Pick; diff --git a/packages/sthrift-verification/test-support/src/pages/page-interfaces/reservation.page-interface.ts b/packages/sthrift-verification/test-support/src/pages/page-interfaces/reservation.page-interface.ts new file mode 100644 index 000000000..4f6991de5 --- /dev/null +++ b/packages/sthrift-verification/test-support/src/pages/page-interfaces/reservation.page-interface.ts @@ -0,0 +1,22 @@ +import type { ReservationPage } from '../reservation.page.ts'; + +export type UiReservationPage = Pick< + ReservationPage, + 'openDatePicker' | 'clickReserve' +>; + +export type E2EReservationPage = Pick< + ReservationPage, + | 'rangePicker' + | 'disabledPicker' + | 'reserveButton' + | 'cancelRequestButton' + | 'loadingIcon' + | 'overlapErrorMessage' + | 'nextMonthButton' + | 'skeleton' + | 'calendarCell' + | 'isDisabled' + | 'isCalendarCellDisabled' + | 'openDatePicker' +>; From 2868cd62e17eb2ba33c24519cd379295e26a98fd Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Mon, 6 Apr 2026 14:10:04 -0400 Subject: [PATCH 7/7] verify fixes --- .../src/shared/support/hooks.ts | 2 +- .../src/shared/support/local-settings.ts | 48 +++++++++---------- .../acceptance-api/src/world.ts | 5 -- .../acceptance-ui/src/shared/support/hooks.ts | 2 +- .../shared/support/ui/asset-loader-hooks.mjs | 2 +- .../src/shared/support/ui/jsdom-setup.ts | 24 +++++----- .../src/shared/support/ui/react-render.tsx | 4 +- .../acceptance-ui/src/world.ts | 7 +-- .../contexts/listing/tasks/create-listing.ts | 4 +- .../create-reservation-request.steps.ts | 16 +++---- .../e2e-tests/src/shared/support/hooks.ts | 2 +- .../src/shared/support/local-settings.ts | 48 +++++++++---------- .../src/shared/support/oauth2-login.ts | 4 +- .../shared/support/servers/portless-server.ts | 2 +- .../support/servers/test-environment.ts | 2 +- .../shared/support/shared-infrastructure.ts | 12 ++--- .../test-support/package.json | 2 - .../src/pages/adapters/jsdom-adapter.ts | 3 +- .../src/test-data/account-plan.test-data.ts | 13 ----- .../src/test-data/appeal-request.test-data.ts | 37 -------------- .../src/test-data/conversation.test-data.ts | 32 ------------- .../src/test-data/user.test-data.ts | 2 +- 22 files changed, 90 insertions(+), 183 deletions(-) delete mode 100644 packages/sthrift-verification/test-support/src/test-data/appeal-request.test-data.ts delete mode 100644 packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts diff --git a/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts index a03c5d6df..0ed79b33e 100644 --- a/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts +++ b/packages/sthrift-verification/acceptance-api/src/shared/support/hooks.ts @@ -25,6 +25,6 @@ After(async function (this: IWorld) { await world.cleanup(); }); -AfterAll(async function () { +AfterAll(async () => { await stopSharedServers(); }); diff --git a/packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts b/packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts index dc294ad58..64bd2b1e2 100644 --- a/packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts +++ b/packages/sthrift-verification/acceptance-api/src/shared/support/local-settings.ts @@ -41,27 +41,27 @@ const uiValues = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-sharethrift', ' // API Settings export const apiSettings = { - nodeEnv: apiValues['NODE_ENV'] ?? 'development', - isDevelopment: (apiValues['NODE_ENV'] ?? 'development') === 'development', + nodeEnv: apiValues.NODE_ENV ?? 'development', + isDevelopment: (apiValues.NODE_ENV ?? 'development') === 'development', - cosmosDbConnectionString: apiValues['COSMOSDB_CONNECTION_STRING'] ?? '', - cosmosDbName: apiValues['COSMOSDB_DBNAME'] ?? 'sharethrift', - cosmosDbPort: Number(apiValues['COSMOSDB_PORT'] ?? '50000'), + cosmosDbConnectionString: apiValues.COSMOSDB_CONNECTION_STRING ?? '', + cosmosDbName: apiValues.COSMOSDB_DBNAME ?? 'sharethrift', + cosmosDbPort: Number(apiValues.COSMOSDB_PORT ?? '50000'), - userPortalOidcIssuer: apiValues['USER_PORTAL_OIDC_ISSUER'] ?? '', - userPortalOidcEndpoint: apiValues['USER_PORTAL_OIDC_ENDPOINT'] ?? '', - userPortalOidcAudience: apiValues['USER_PORTAL_OIDC_AUDIENCE'] ?? 'user-portal', + userPortalOidcIssuer: apiValues.USER_PORTAL_OIDC_ISSUER ?? '', + userPortalOidcEndpoint: apiValues.USER_PORTAL_OIDC_ENDPOINT ?? '', + userPortalOidcAudience: apiValues.USER_PORTAL_OIDC_AUDIENCE ?? 'user-portal', - adminPortalOidcIssuer: apiValues['ADMIN_PORTAL_OIDC_ISSUER'] ?? '', - adminPortalOidcEndpoint: apiValues['ADMIN_PORTAL_OIDC_ENDPOINT'] ?? '', - adminPortalOidcAudience: apiValues['ADMIN_PORTAL_OIDC_AUDIENCE'] ?? 'admin-portal', + adminPortalOidcIssuer: apiValues.ADMIN_PORTAL_OIDC_ISSUER ?? '', + adminPortalOidcEndpoint: apiValues.ADMIN_PORTAL_OIDC_ENDPOINT ?? '', + adminPortalOidcAudience: apiValues.ADMIN_PORTAL_OIDC_AUDIENCE ?? 'admin-portal', - apiGraphqlUrl: apiValues['VITE_FUNCTION_ENDPOINT'] || (() => { + apiGraphqlUrl: apiValues.VITE_FUNCTION_ENDPOINT || (() => { throw new Error('VITE_FUNCTION_ENDPOINT is required in local.settings.json'); })(), - messagingMockUrl: apiValues['MESSAGING_MOCK_URL'] ?? '', - paymentMockUrl: apiValues['PAYMENT_MOCK_URL'] ?? '', + messagingMockUrl: apiValues.MESSAGING_MOCK_URL ?? '', + paymentMockUrl: apiValues.PAYMENT_MOCK_URL ?? '', // Directories apiDir: path.join(workspaceRoot, 'apps', 'api'), @@ -70,28 +70,28 @@ export const apiSettings = { } as const; // UI Settings -const uiBaseUrl = uiValues['VITE_BASE_URL'] || (() => { +const uiBaseUrl = uiValues.VITE_BASE_URL || (() => { throw new Error('VITE_BASE_URL is required in .env'); })(); export const uiSettings = { baseUrl: uiBaseUrl, - clientId: uiValues['VITE_B2C_CLIENTID'] ?? 'mock-client', - authority: uiValues['VITE_B2C_AUTHORITY'] ?? apiSettings.userPortalOidcIssuer, - redirectUri: uiValues['VITE_B2C_REDIRECT_URI'] || (() => { + clientId: uiValues.VITE_B2C_CLIENTID ?? 'mock-client', + authority: uiValues.VITE_B2C_AUTHORITY ?? apiSettings.userPortalOidcIssuer, + redirectUri: uiValues.VITE_B2C_REDIRECT_URI || (() => { throw new Error('VITE_B2C_REDIRECT_URI is required in .env'); })(), - scope: uiValues['VITE_B2C_SCOPE'] ?? 'openid user-portal', + scope: uiValues.VITE_B2C_SCOPE ?? 'openid user-portal', - adminClientId: uiValues['VITE_B2C_ADMIN_CLIENTID'] ?? 'mock-client', - adminAuthority: uiValues['VITE_B2C_ADMIN_AUTHORITY'] ?? apiSettings.adminPortalOidcIssuer, - adminRedirectUri: uiValues['VITE_B2C_ADMIN_REDIRECT_URI'] || (() => { + adminClientId: uiValues.VITE_B2C_ADMIN_CLIENTID ?? 'mock-client', + adminAuthority: uiValues.VITE_B2C_ADMIN_AUTHORITY ?? apiSettings.adminPortalOidcIssuer, + adminRedirectUri: uiValues.VITE_B2C_ADMIN_REDIRECT_URI || (() => { throw new Error('VITE_B2C_ADMIN_REDIRECT_URI is required in .env'); })(), - adminScope: uiValues['VITE_B2C_ADMIN_SCOPE'] ?? 'openid admin-portal', + adminScope: uiValues.VITE_B2C_ADMIN_SCOPE ?? 'openid admin-portal', - graphqlEndpoint: uiValues['VITE_FUNCTION_ENDPOINT'] || (() => { + graphqlEndpoint: uiValues.VITE_FUNCTION_ENDPOINT || (() => { throw new Error('VITE_FUNCTION_ENDPOINT is required in .env'); })(), } as const; diff --git a/packages/sthrift-verification/acceptance-api/src/world.ts b/packages/sthrift-verification/acceptance-api/src/world.ts index 7628648ad..59d659a4d 100644 --- a/packages/sthrift-verification/acceptance-api/src/world.ts +++ b/packages/sthrift-verification/acceptance-api/src/world.ts @@ -1,7 +1,6 @@ import { setWorldConstructor, World, - type IWorldOptions, } from '@cucumber/cucumber'; import { engage } from '@serenity-js/core'; import { @@ -19,10 +18,6 @@ export async function stopSharedServers(): Promise { export class ShareThriftApiWorld extends World { private apiUrl = ''; - constructor(options: IWorldOptions) { - super(options); - } - async init(): Promise { await infra.ensureApiServers(); diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts index d8059ce50..2cb75b050 100644 --- a/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/hooks.ts @@ -1,7 +1,7 @@ import type { IWorld } from '@cucumber/cucumber'; import { After, Before, setDefaultTimeout } from '@cucumber/cucumber'; import { isAgent } from 'std-env'; -import { type ShareThriftUiWorld } from '../../world.ts'; +import type { ShareThriftUiWorld } from '../../world.ts'; let printedSuiteHeader = false; diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs index a01e24343..019d5cb48 100644 --- a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/asset-loader-hooks.mjs @@ -29,7 +29,7 @@ export async function resolve(specifier, context, nextResolve) { return nextResolve(specifier); } -export async function load(url, context, nextLoad) { +export function load(url, context, nextLoad) { if (ASSET_PATTERN.test(url)) { return { format: 'module', diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts index 9efb82285..b42762682 100644 --- a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/jsdom-setup.ts @@ -61,10 +61,10 @@ export function ensureJsdom(): void { // Mock matchMedia (required by antd responsive components) const matchMediaMock = () => ({ matches: false, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, dispatchEvent: () => false, media: '', onchange: null, @@ -74,16 +74,16 @@ export function ensureJsdom(): void { // Mock ResizeObserver (required by antd) globalThis.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} + observe() { /* no-op */ } + unobserve() { /* no-op */ } + disconnect() { /* no-op */ } } as unknown as typeof ResizeObserver; // Mock IntersectionObserver globalThis.IntersectionObserver = class IntersectionObserver { - observe() {} - unobserve() {} - disconnect() {} + observe() { /* no-op */ } + unobserve() { /* no-op */ } + disconnect() { /* no-op */ } root = null; rootMargin = ''; thresholds = [] as number[]; @@ -96,8 +96,8 @@ export function ensureJsdom(): void { } // Mock scroll and selection APIs - window.scrollTo = () => {}; - globalThis.scrollTo = () => {}; + window.scrollTo = () => undefined; + globalThis.scrollTo = () => undefined; window.getSelection = () => null as unknown as Selection; // Mock requestAnimationFrame (not always present in jsdom) diff --git a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx index bcd8b24d2..506d54a05 100644 --- a/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx +++ b/packages/sthrift-verification/acceptance-ui/src/shared/support/ui/react-render.tsx @@ -32,8 +32,6 @@ export async function renderForCoverage

>( const container = document.createElement('div'); document.body.appendChild(container); - let root: Root | undefined; - const withRouter = options?.withRouter ?? true; let element: ReactElement; @@ -49,7 +47,7 @@ export async function renderForCoverage

>( element = componentElement; } - root = createRoot(container); + const root: Root = createRoot(container); root.render(element); // Allow React to flush the render diff --git a/packages/sthrift-verification/acceptance-ui/src/world.ts b/packages/sthrift-verification/acceptance-ui/src/world.ts index dd6b4b76d..79948d6da 100644 --- a/packages/sthrift-verification/acceptance-ui/src/world.ts +++ b/packages/sthrift-verification/acceptance-ui/src/world.ts @@ -1,7 +1,6 @@ import { setWorldConstructor, World, - type IWorldOptions, } from '@cucumber/cucumber'; import { engage } from '@serenity-js/core'; import { @@ -12,14 +11,12 @@ import './shared/support/hooks.ts'; import { ShareThriftUiCast } from './shared/support/cast.ts'; export class ShareThriftUiWorld extends World { - constructor(options: IWorldOptions) { - super(options); - } - async init(): Promise { + init(): Promise { clearMockReservationRequests(); clearMockListings(); engage(new ShareThriftUiCast()); + return Promise.resolve(); } async cleanup(): Promise { diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts index b335437e1..6951c21eb 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/listing/tasks/create-listing.ts @@ -180,10 +180,10 @@ export class CreateListing extends Task { } // Extract listing ID from the GraphQL mutation response - const listing = mutationResult.data?.['listing'] as + const listing = mutationResult.data?.listing as | Record | undefined; - const listingId = String(listing?.['id'] ?? 'e2e-unknown'); + const listingId = String(listing?.id ?? 'e2e-unknown'); await actor.attemptsTo( notes().set('lastListingId', listingId), diff --git a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts index 96027c4fe..7196f4b46 100644 --- a/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts +++ b/packages/sthrift-verification/e2e-tests/src/contexts/reservation-request/step-definitions/create-reservation-request.steps.ts @@ -61,8 +61,8 @@ When( const data = dataTable.rowsHash(); const listingId = await getListingIdFromOwner(owner); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ @@ -89,8 +89,8 @@ When( ); try { - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; const listingId = await getListingIdFromOwner('Bob'); @@ -309,8 +309,8 @@ Given( const data = dataTable.rowsHash(); const listingId = await getListingIdFromOwner(owner); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ @@ -336,8 +336,8 @@ When( try { const listingId = await getListingIdFromOwner('Bob'); - const startDate = data['reservationPeriodStart']; - const endDate = data['reservationPeriodEnd']; + const startDate = data.reservationPeriodStart; + const endDate = data.reservationPeriodEnd; await actor.attemptsTo( CreateReservationRequest.with({ diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts index 8e2347300..03455654e 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/hooks.ts @@ -40,6 +40,6 @@ After(async function (this: IWorld, { result, pickle }: ITestCaseHookParameter) await world.cleanup(); }); -AfterAll(async function () { +AfterAll(async () => { await stopSharedServers(); }); diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/local-settings.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/local-settings.ts index dc294ad58..64bd2b1e2 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/local-settings.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/local-settings.ts @@ -41,27 +41,27 @@ const uiValues = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-sharethrift', ' // API Settings export const apiSettings = { - nodeEnv: apiValues['NODE_ENV'] ?? 'development', - isDevelopment: (apiValues['NODE_ENV'] ?? 'development') === 'development', + nodeEnv: apiValues.NODE_ENV ?? 'development', + isDevelopment: (apiValues.NODE_ENV ?? 'development') === 'development', - cosmosDbConnectionString: apiValues['COSMOSDB_CONNECTION_STRING'] ?? '', - cosmosDbName: apiValues['COSMOSDB_DBNAME'] ?? 'sharethrift', - cosmosDbPort: Number(apiValues['COSMOSDB_PORT'] ?? '50000'), + cosmosDbConnectionString: apiValues.COSMOSDB_CONNECTION_STRING ?? '', + cosmosDbName: apiValues.COSMOSDB_DBNAME ?? 'sharethrift', + cosmosDbPort: Number(apiValues.COSMOSDB_PORT ?? '50000'), - userPortalOidcIssuer: apiValues['USER_PORTAL_OIDC_ISSUER'] ?? '', - userPortalOidcEndpoint: apiValues['USER_PORTAL_OIDC_ENDPOINT'] ?? '', - userPortalOidcAudience: apiValues['USER_PORTAL_OIDC_AUDIENCE'] ?? 'user-portal', + userPortalOidcIssuer: apiValues.USER_PORTAL_OIDC_ISSUER ?? '', + userPortalOidcEndpoint: apiValues.USER_PORTAL_OIDC_ENDPOINT ?? '', + userPortalOidcAudience: apiValues.USER_PORTAL_OIDC_AUDIENCE ?? 'user-portal', - adminPortalOidcIssuer: apiValues['ADMIN_PORTAL_OIDC_ISSUER'] ?? '', - adminPortalOidcEndpoint: apiValues['ADMIN_PORTAL_OIDC_ENDPOINT'] ?? '', - adminPortalOidcAudience: apiValues['ADMIN_PORTAL_OIDC_AUDIENCE'] ?? 'admin-portal', + adminPortalOidcIssuer: apiValues.ADMIN_PORTAL_OIDC_ISSUER ?? '', + adminPortalOidcEndpoint: apiValues.ADMIN_PORTAL_OIDC_ENDPOINT ?? '', + adminPortalOidcAudience: apiValues.ADMIN_PORTAL_OIDC_AUDIENCE ?? 'admin-portal', - apiGraphqlUrl: apiValues['VITE_FUNCTION_ENDPOINT'] || (() => { + apiGraphqlUrl: apiValues.VITE_FUNCTION_ENDPOINT || (() => { throw new Error('VITE_FUNCTION_ENDPOINT is required in local.settings.json'); })(), - messagingMockUrl: apiValues['MESSAGING_MOCK_URL'] ?? '', - paymentMockUrl: apiValues['PAYMENT_MOCK_URL'] ?? '', + messagingMockUrl: apiValues.MESSAGING_MOCK_URL ?? '', + paymentMockUrl: apiValues.PAYMENT_MOCK_URL ?? '', // Directories apiDir: path.join(workspaceRoot, 'apps', 'api'), @@ -70,28 +70,28 @@ export const apiSettings = { } as const; // UI Settings -const uiBaseUrl = uiValues['VITE_BASE_URL'] || (() => { +const uiBaseUrl = uiValues.VITE_BASE_URL || (() => { throw new Error('VITE_BASE_URL is required in .env'); })(); export const uiSettings = { baseUrl: uiBaseUrl, - clientId: uiValues['VITE_B2C_CLIENTID'] ?? 'mock-client', - authority: uiValues['VITE_B2C_AUTHORITY'] ?? apiSettings.userPortalOidcIssuer, - redirectUri: uiValues['VITE_B2C_REDIRECT_URI'] || (() => { + clientId: uiValues.VITE_B2C_CLIENTID ?? 'mock-client', + authority: uiValues.VITE_B2C_AUTHORITY ?? apiSettings.userPortalOidcIssuer, + redirectUri: uiValues.VITE_B2C_REDIRECT_URI || (() => { throw new Error('VITE_B2C_REDIRECT_URI is required in .env'); })(), - scope: uiValues['VITE_B2C_SCOPE'] ?? 'openid user-portal', + scope: uiValues.VITE_B2C_SCOPE ?? 'openid user-portal', - adminClientId: uiValues['VITE_B2C_ADMIN_CLIENTID'] ?? 'mock-client', - adminAuthority: uiValues['VITE_B2C_ADMIN_AUTHORITY'] ?? apiSettings.adminPortalOidcIssuer, - adminRedirectUri: uiValues['VITE_B2C_ADMIN_REDIRECT_URI'] || (() => { + adminClientId: uiValues.VITE_B2C_ADMIN_CLIENTID ?? 'mock-client', + adminAuthority: uiValues.VITE_B2C_ADMIN_AUTHORITY ?? apiSettings.adminPortalOidcIssuer, + adminRedirectUri: uiValues.VITE_B2C_ADMIN_REDIRECT_URI || (() => { throw new Error('VITE_B2C_ADMIN_REDIRECT_URI is required in .env'); })(), - adminScope: uiValues['VITE_B2C_ADMIN_SCOPE'] ?? 'openid admin-portal', + adminScope: uiValues.VITE_B2C_ADMIN_SCOPE ?? 'openid admin-portal', - graphqlEndpoint: uiValues['VITE_FUNCTION_ENDPOINT'] || (() => { + graphqlEndpoint: uiValues.VITE_FUNCTION_ENDPOINT || (() => { throw new Error('VITE_FUNCTION_ENDPOINT is required in .env'); })(), } as const; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts index be64e69a4..f79af465b 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -25,8 +25,8 @@ function loadTestCredentials(): { username: string; password: string } { } return { - username: process.env['E2E_USERNAME'] || defaults['E2E_USERNAME'] || 'test@sharethrift.local', - password: process.env['E2E_PASSWORD'] || defaults['E2E_PASSWORD'] || '', + username: process.env.E2E_USERNAME || defaults.E2E_USERNAME || 'test@sharethrift.local', + password: process.env.E2E_PASSWORD || defaults.E2E_PASSWORD || '', }; } diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts index ad0939d24..8a0cfb48a 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/portless-server.ts @@ -32,7 +32,7 @@ export abstract class PortlessServer { this.process = spawn(getPortlessPath(), this.spawnArgs, { cwd: this.cwd, - env: { ...process.env, PORTLESS_STATE_DIR: `${process.env['HOME']}/.portless`, ...this.extraEnv }, + env: { ...process.env, PORTLESS_STATE_DIR: `${process.env.HOME}/.portless`, ...this.extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }); this.startedByUs = true; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts index 92436b38b..2e6ad5206 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/servers/test-environment.ts @@ -13,7 +13,7 @@ export function initTestEnvironment() { execFileSync(getPortlessPath(), ['proxy', 'start'], { timeout: 15_000, stdio: 'pipe', - env: { ...process.env, PORTLESS_STATE_DIR: `${process.env['HOME']}/.portless` }, + env: { ...process.env, PORTLESS_STATE_DIR: `${process.env.HOME}/.portless` }, }); proxyInitialized = true; diff --git a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index c369dd5ab..bc8ff16f8 100644 --- a/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/sthrift-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -5,11 +5,11 @@ import { defaultActor } from '@sthrift-verification/test-support/test-data'; import { performOAuth2Login } from './oauth2-login.ts'; import { apiSettings } from './local-settings.ts'; -const isDeployedE2E = process.env['E2E_DEPLOYED'] === 'true'; -const deployedApiUrl = process.env['E2E_API_URL']; -const deployedUiUrl = process.env['E2E_UI_URL']; -const deployedIgnoreHttpsErrors = process.env['E2E_IGNORE_HTTPS_ERRORS'] === 'true'; -const skipDeployedUiLogin = process.env['E2E_SKIP_UI_LOGIN'] === 'true'; +const isDeployedE2E = process.env.E2E_DEPLOYED === 'true'; +const deployedApiUrl = process.env.E2E_API_URL; +const deployedUiUrl = process.env.E2E_UI_URL; +const deployedIgnoreHttpsErrors = process.env.E2E_IGNORE_HTTPS_ERRORS === 'true'; +const skipDeployedUiLogin = process.env.E2E_SKIP_UI_LOGIN === 'true'; // Shared infrastructure — persists across scenarios within a single test run let mongoDBServer: MongoDBTestServer | undefined; @@ -123,7 +123,7 @@ async function initDeployedE2E(): Promise { apiUrl = deployedApiUrl; browserBaseUrl = deployedUiUrl; - accessToken = process.env['E2E_ACCESS_TOKEN'] ?? undefined; + accessToken = process.env.E2E_ACCESS_TOKEN ?? undefined; if (!browser) { browser = await chromium.launch({ headless: true, args: ['--headless=new'] }); diff --git a/packages/sthrift-verification/test-support/package.json b/packages/sthrift-verification/test-support/package.json index 8ae12cc47..75d9b4420 100644 --- a/packages/sthrift-verification/test-support/package.json +++ b/packages/sthrift-verification/test-support/package.json @@ -25,8 +25,6 @@ "jsdom": ">=24.0.0" }, "peerDependenciesMeta": { - "@playwright/test": { "optional": true }, - "@testing-library/react": { "optional": true }, "jsdom": { "optional": true } } } diff --git a/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts b/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts index 142a7b1f7..f737f0163 100644 --- a/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts +++ b/packages/sthrift-verification/test-support/src/pages/adapters/jsdom-adapter.ts @@ -194,13 +194,14 @@ export class JsdomPageAdapter implements PageAdapter { return new JsdomElementHandle(null); } - async goto( + goto( url: string, _options?: { timeout?: number; waitUntil?: PageNavigationWaitUntil }, ): Promise { if (typeof window !== 'undefined') { window.history.pushState({}, '', url); } + return Promise.resolve(); } waitForURL( diff --git a/packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts index fed70d2e4..134d8ae8a 100644 --- a/packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts +++ b/packages/sthrift-verification/test-support/src/test-data/account-plan.test-data.ts @@ -1,7 +1,5 @@ import type { Domain } from '@sthrift/domain'; -let accountPlanCounter = 1; - const accountPlans = new Map([ [ '607f1f77bcf86cd799439001', @@ -49,17 +47,6 @@ const accountPlans = new Map(); -const userAppeals = new Map(); - -let listingAppealCounter = 1; -let userAppealCounter = 1; - -export function createMockListingAppeal(): Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference { - const appeal = { - id: `listing-appeal-${listingAppealCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference; - listingAppeals.set(appeal.id, appeal); - listingAppealCounter++; - return appeal; -} - -export function createMockUserAppeal(): Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference { - const appeal = { - id: `user-appeal-${userAppealCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference; - userAppeals.set(appeal.id, appeal); - userAppealCounter++; - return appeal; -} - -export function getAllMockListingAppeals(): Domain.Contexts.AppealRequest.ListingAppealRequest.ListingAppealRequestEntityReference[] { - return Array.from(listingAppeals.values()); -} - -export function getAllMockUserAppeals(): Domain.Contexts.AppealRequest.UserAppealRequest.UserAppealRequestEntityReference[] { - return Array.from(userAppeals.values()); -} diff --git a/packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts deleted file mode 100644 index 4773abc7e..000000000 --- a/packages/sthrift-verification/test-support/src/test-data/conversation.test-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Domain } from '@sthrift/domain'; - -const conversations = new Map(); -const messages = new Map(); - -let conversationCounter = 1; -let messageCounter = 1; - -export function createMockConversation(): Domain.Contexts.Conversation.Conversation.ConversationEntityReference { - const conversation = { - id: `conversation-${conversationCounter}`, - createdAt: new Date(), - updatedAt: new Date(), - } as Domain.Contexts.Conversation.Conversation.ConversationEntityReference; - conversations.set(conversation.id, conversation); - conversationCounter++; - return conversation; -} - -export function createMockMessage(): Domain.Contexts.Conversation.Conversation.MessageEntityReference { - const message = { - id: `message-${messageCounter}`, - createdAt: new Date(), - } as Domain.Contexts.Conversation.Conversation.MessageEntityReference; - messages.set(message.id, message); - messageCounter++; - return message; -} - -export function getAllMockConversations(): Domain.Contexts.Conversation.Conversation.ConversationEntityReference[] { - return Array.from(conversations.values()); -} diff --git a/packages/sthrift-verification/test-support/src/test-data/user.test-data.ts b/packages/sthrift-verification/test-support/src/test-data/user.test-data.ts index 4460db09b..8d5abe101 100644 --- a/packages/sthrift-verification/test-support/src/test-data/user.test-data.ts +++ b/packages/sthrift-verification/test-support/src/test-data/user.test-data.ts @@ -64,7 +64,7 @@ const aliceId = generateObjectId(); const bobId = generateObjectId(); const adminId = generateObjectId(); -export const users = new Map([ +const users = new Map([ [ aliceId, {