Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
version: v1.5.0
ignore:
'SNYK-JS-OPENTELEMETRYPROPAGATORJAEGER-17901201':
- '* > @opentelemetry/propagator-jaeger':
reason: 'Transitive dependency bundled by @opentelemetry/sdk-node; the fix requires a major-version upgrade of the whole otel suite (deferred, same as SNYK-JS-OPENTELEMETRYCORE). Trace context is internal, not user-controlled.'
expires: '2026-10-08T00:00:00.000Z'
created: '2026-07-09T00:00:00.000Z'
'SNYK-JS-SIRV-12558119':
- '* > sirv@2.0.4':
reason: 'Transitive dependency in Docusaurus; not exploitable in static site serving context (dev-only asset handler)'
Expand Down
43 changes: 43 additions & 0 deletions apps/ui-sharethrift/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,49 @@ export default defineConfig(() => {
// Skip auto-opening a browser under E2E, where a headless Playwright
// browser drives the app and a real window must not be spawned.
open: process.env.E2E === 'true' ? false : 'https://sharethrift.localhost:1355',
// Pre-transform the app entry at server startup so the first navigation
// to a route doesn't stall while Vite compiles the module graph.
warmup: {
clientFiles: ['./src/main.tsx'],
},
},
// The UI is composed from linked workspace packages (@sthrift/ui-shared and the
// route packages) that resolve to source. By default Vite does NOT pre-bundle
// linked packages, so it serves every one of their hundreds of component files as
// a separate module request. Behind the portless HTTP/2 proxy, that request storm
// intermittently fails with net::ERR_HTTP2_PROTOCOL_ERROR, which breaks the render
// and leaves a ~10-15s white screen until the page reloads.
//
// Including these packages forces Vite to pre-bundle each into a single optimized
// chunk, collapsing hundreds of parallel /@fs/ requests into a handful and removing
// the HTTP/2 failure mode. The third-party heavy deps are listed too so they are
// bundled up front rather than discovered on-demand (which triggers a re-optimize
// + full-page reload).
//
// Trade-off: edits to these workspace packages trigger a Vite re-optimize (full
// reload) instead of granular HMR. If you are actively iterating inside one of
// them, temporarily remove it from this list.
optimizeDeps: {
include: [
'@sthrift/ui-shared',
'@sthrift/ui-sharethrift-route-root',
'@sthrift/ui-sharethrift-route-shared',
'@sthrift/ui-sharethrift-route-signup',
'antd',
'@ant-design/icons',
'@ant-design/v5-patch-for-react-19',
'@apollo/client',
'@apollo/client/react',
'@apollo/client/link/http',
'@apollo/client/link/context',
'@apollo/client/link/batch-http',
'@apollo/client/link/persisted-queries',
'graphql',
'lodash',
'rxjs',
'react-oidc-context',
'react-router-dom',
],
},
};
});
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,21 @@
"@apollo/server": ">=5.5.0",
"@types/node": "^24.10.7",
"axios": "1.16.0",
"body-parser@2.2.2": "2.3.0",
"brace-expansion@5.0.4": "5.0.5",
"express@4": ">=4.22.2",
"follow-redirects": "1.16.0",
"happy-dom@20.8.4": "20.8.9",
"js-yaml@3.14.2": "3.15.0",
"js-yaml@4.1.1": "4.3.0",
"lodash": "^4.18.1",
"minimatch": ">=10.2.3",
"node-forge@1.3.3": "1.4.0",
"path-to-regexp@0.1.12": "0.1.13",
"picomatch@2.3.1": "2.3.2",
"picomatch@4.0.3": "4.0.4",
"postcss": "8.5.10",
"protobufjs": "7.5.5",
"protobufjs": "7.6.5",
"qs": ">=6.14.2",
"serialize-javascript@6.0.2": "7.0.5",
"serialize-javascript@7.0.4": "7.0.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/sthrift-verification/e2e-tests/cucumber.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isAgent } from 'std-env';
const terminalFormat = isAgent ? '@cellix/serenity-framework/formatters/agent' : 'progress-bar';

export default {
paths: ['../verification-shared/src/scenarios/**/*.feature'],
paths: ['../verification-shared/src/scenarios/**/*.feature', 'src/scenarios/**/*.feature'],
import: ['src/world.ts', 'src/contexts/**/step-definitions/**/*.steps.ts', 'src/shared/**/*.ts'],
format: [terminalFormat, 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'],
formatOptions: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { PageAdapter } from '@cellix/serenity-framework/pages';
export class AccountSettingsPage {
constructor(private readonly adapter: PageAdapter) {}
get profileInformation() {
return this.adapter.getByText('Profile Information');
}
get editProfileButton() {
return this.adapter.getByRole('button', { name: 'Edit Profile' });
}
get firstNameInput() {
return this.adapter.getByLabel('First Name');
}
get saveButton() {
return this.adapter.getByRole('button', { name: 'Save' });
}
firstName(value: string) {
return this.adapter.getByText(value, { selector: '.ant-card' });
}
async changeFirstName(value: string): Promise<void> {
await this.editProfileButton.click();
await this.firstNameInput.waitFor({ state: 'visible', timeout: 15_000 });
await this.firstNameInput.fill(value);
await this.saveButton.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser';
import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { AccountSettingsPage } from '../pages/account-settings.page.ts';
export const AccountSettingsContent = Question.about('editable account settings', async (actor: AnswersQuestions & UsesAbilities) => {
const view = new AccountSettingsPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page));
await view.profileInformation.waitFor({ state: 'visible', timeout: 15_000 });
return (await view.profileInformation.isVisible()) && (await view.editProfileButton.isVisible());
});

export const SavedFirstName = (value: string) =>
Question.about(`saved first name "${value}"`, async (actor: AnswersQuestions & UsesAbilities) => {
const view = new AccountSettingsPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page));
const name = view.firstName(value);
await name.waitFor({ state: 'visible', timeout: 15_000 });
return name.isVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Then, When } from '@cucumber/cucumber';
import { Ensure, equals } from '@serenity-js/assertions';
import { actorCalled } from '@serenity-js/core';
import type { ShareThriftWorld } from '../../../world.ts';
import { SavedFirstName } from '../questions/account-settings-content.ts';
import { ChangeFirstName, OpenAccountSettings } from '../tasks/open-account-settings.ts';

When('{word} opens her account settings', async function (this: ShareThriftWorld, name: string) {
await actorCalled(name).attemptsTo(OpenAccountSettings.fromNavigation());
});
When('{word} changes her first name to {string}', async function (this: ShareThriftWorld, name: string, value: string) {
await actorCalled(name).attemptsTo(ChangeFirstName.to(value));
});
Then('{word} should see the saved first name {string}', async function (this: ShareThriftWorld, name: string, value: string) {
await actorCalled(name).attemptsTo(Ensure.that(SavedFirstName(value), equals(true)));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser';
import { type Actor, Task } from '@serenity-js/core';
import { NavigationPage } from '../../../shared/pages/navigation.page.ts';
import { AccountSettingsPage } from '../pages/account-settings.page.ts';
export class OpenAccountSettings extends Task {
static fromNavigation() {
return new OpenAccountSettings();
}
private constructor() {
super('opens account settings from the navigation');
}
async performAs(actor: Actor) {
const { page } = BrowseTheWeb.withActor(actor);
await new NavigationPage(new PlaywrightPageAdapter(page)).open('Settings');
}
}

export class ChangeFirstName extends Task {
static to(value: string) {
return new ChangeFirstName(value);
}
private constructor(private readonly value: string) {
super(`changes the first name to "${value}"`);
}
async performAs(actor: Actor) {
const { page } = BrowseTheWeb.withActor(actor);
const updateResponse = page.waitForResponse((response) => {
const body = response.request().postData();
return Boolean(body?.includes('HomeAccountSettingsViewContainerUpdatePersonalUser') && body.includes('"query"'));
});
await new AccountSettingsPage(new PlaywrightPageAdapter(page)).changeFirstName(this.value);
const responseBody = (await (await updateResponse).json()) as
| { data?: { personalUserUpdate?: { status?: { success?: boolean; errorMessage?: string } } } }
| Array<{ data?: { personalUserUpdate?: { status?: { success?: boolean; errorMessage?: string } } } }>;
const json = Array.isArray(responseBody) ? responseBody[0] : responseBody;
if (!json.data?.personalUserUpdate?.status?.success) {
throw new Error(`Saving account details failed: ${json.data?.personalUserUpdate?.status?.errorMessage ?? JSON.stringify(json)}`);
}
await page.waitForLoadState('networkidle').catch(() => undefined);
await page.reload({ waitUntil: 'domcontentloaded' });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { PageAdapter } from '@cellix/serenity-framework/pages';

export class HomePage {
constructor(private readonly adapter: PageAdapter) {}

get searchInput() {
return this.adapter.getByPlaceholder('Search');
}
get categoryFilter() {
return this.adapter.getByText('All');
}
get locationFilter() {
return this.adapter.getByText('Philadelphia, PA');
}
listing(title: string) {
return this.adapter.getByText(title);
}
selectedCategory(category: string) {
return this.adapter.locator(`.ant-select-selection-item[title="${category}"]:visible`);
}

async visit(): Promise<void> {
await this.adapter.goto('/', { waitUntil: 'domcontentloaded' });
await this.searchInput.waitFor({ state: 'visible', timeout: 15_000 });
}

async searchFor(query: string): Promise<void> {
await this.searchInput.fill(query);
await this.adapter.locator('button:has(.anticon-search):visible').click();
}

async openListing(title: string): Promise<void> {
await this.listing(title).click();
await this.adapter.waitForURL(/\/listing\//, { timeout: 15_000 });
}

async filterByCategory(category: string): Promise<void> {
await this.adapter.locator('.ant-select-selector:visible').click();
const option = this.adapter.locator(`.ant-select-item-option[title="${category}"]:visible`);
await option.waitFor({ state: 'visible', timeout: 15_000 });
await option.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser';
import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { HomePage } from '../pages/home.page.ts';

function pageFor(actor: AnswersQuestions & UsesAbilities) {
return new HomePage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page));
}

export const DiscoveryControls = Question.about('the home discovery controls', async (actor: AnswersQuestions & UsesAbilities) => {
const home = pageFor(actor);
await home.searchInput.waitFor({ state: 'visible', timeout: 15_000 });
return (await home.categoryFilter.isVisible()) && (await home.locationFilter.isVisible());
});

export const ListingInDiscovery = (title: string) =>
Question.about(`listing "${title}" on the home page`, async (actor: AnswersQuestions & UsesAbilities) => {
const listing = pageFor(actor).listing(title);
await listing.waitFor({ state: 'visible', timeout: 15_000 });
return listing.isVisible();
});

export const SelectedCategory = (category: string) =>
Question.about(`selected category "${category}"`, async (actor: AnswersQuestions & UsesAbilities) => {
const selected = pageFor(actor).selectedCategory(category);
await selected.waitFor({ state: 'visible', timeout: 15_000 });
return selected.isVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Given, Then, When } from '@cucumber/cucumber';
import { Ensure, equals } from '@serenity-js/assertions';
import { actorCalled } from '@serenity-js/core';
import type { ShareThriftWorld } from '../../../world.ts';
import { CreateListing } from '../../listing/tasks/create-listing.ts';
import { DiscoveryControls, ListingInDiscovery, SelectedCategory } from '../questions/home-page-content.ts';
import { BrowseListings } from '../tasks/browse-listings.ts';

Given('{word} has created a published listing titled {string}', async function (this: ShareThriftWorld, actorName: string, title: string) {
await actorCalled(actorName).attemptsTo(CreateListing.with({ title, description: 'Portable projector for neighborhood movie nights', category: 'Electronics', location: 'Philadelphia, PA', isDraft: false }));
});
When('{word} browses available listings', async function (this: ShareThriftWorld, actorName: string) {
await actorCalled(actorName).attemptsTo(BrowseListings.onTheHomePage());
});
When('{word} searches listings for {string}', async function (this: ShareThriftWorld, actorName: string, query: string) {
await actorCalled(actorName).attemptsTo(BrowseListings.matching(query));
});
When('{word} opens listing {string}', async function (this: ShareThriftWorld, actorName: string, title: string) {
await actorCalled(actorName).attemptsTo(BrowseListings.open(title));
});
When('{word} filters listings by {string}', async function (this: ShareThriftWorld, actorName: string, category: string) {
await actorCalled(actorName).attemptsTo(BrowseListings.inCategory(category));
});
Then('{word} should see listing search, category, and location controls', async function (this: ShareThriftWorld, actorName: string) {
await actorCalled(actorName).attemptsTo(Ensure.that(DiscoveryControls, equals(true)));
});
Then('{word} should find the listing {string}', async function (this: ShareThriftWorld, actorName: string, title: string) {
await actorCalled(actorName).attemptsTo(Ensure.that(ListingInDiscovery(title), equals(true)));
});
Then('{word} should see listing {string} details', async function (this: ShareThriftWorld, actorName: string, title: string) {
await actorCalled(actorName).attemptsTo(Ensure.that(ListingInDiscovery(title), equals(true)));
});
Then('{word} should see {string} as the selected category', async function (this: ShareThriftWorld, actorName: string, category: string) {
await actorCalled(actorName).attemptsTo(Ensure.that(SelectedCategory(category), equals(true)));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser';
import { type Actor, Task } from '@serenity-js/core';
import { HomePage } from '../pages/home.page.ts';

export class BrowseListings extends Task {
static onTheHomePage() {
return new BrowseListings('browses listings on the home page', 'visit');
}
static matching(query: string) {
return new BrowseListings(`searches listings for "${query}"`, 'search', query);
}
static open(title: string) {
return new BrowseListings(`opens listing "${title}"`, 'open', title);
}
static inCategory(category: string) {
return new BrowseListings(`filters listings by "${category}"`, 'category', category);
}

private constructor(
description: string,
private readonly action: 'visit' | 'search' | 'open' | 'category',
private readonly value?: string,
) {
super(description);
}

async performAs(actor: Actor): Promise<void> {
const { page } = BrowseTheWeb.withActor(actor);
const home = new HomePage(new PlaywrightPageAdapter(page));
if (this.action === 'visit') await home.visit();
if (this.action === 'search') await home.searchFor(this.value ?? '');
if (this.action === 'open') await home.openListing(this.value ?? '');
if (this.action === 'category') await home.filterByCategory(this.value ?? '');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { PageAdapter } from '@cellix/serenity-framework/pages';

export class ListingManagementPage {
constructor(private readonly adapter: PageAdapter) {}
get heading() {
return this.adapter.getByRole('heading', { name: 'My Listings' });
}
get allListingsTab() {
return this.adapter.getByRole('tab', { name: 'All Listings' });
}
get requestsTab() {
return this.adapter.getByRole('tab', { name: /Requests/ });
}
get createListingHeading() {
return this.adapter.getByText('Create a Listing');
}
async viewRequests(): Promise<void> {
await this.requestsTab.click();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ElementHandle } from '@cellix/serenity-framework/pages';
import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright';
import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser';
import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { ListingManagementPage } from '../pages/listing-management.page.ts';

async function visible(actor: AnswersQuestions & UsesAbilities, element: (page: ListingManagementPage) => ElementHandle) {
const page = new ListingManagementPage(new PlaywrightPageAdapter(BrowseTheWeb.withActor(actor).page));
const handle = element(page);
await handle.waitFor({ state: 'visible', timeout: 15_000 });
return handle.isVisible();
}

export const ListingsDashboard = Question.about('the listings dashboard', (actor) => visible(actor, (page) => page.heading));
export const IncomingRequestsTab = Question.about('the incoming requests tab', (actor) => visible(actor, (page) => page.requestsTab));
export const CreateListingForm = Question.about('the create listing form', (actor) => visible(actor, (page) => page.createListingHeading));
Loading