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
11 changes: 6 additions & 5 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,8 @@ ignore:
expires: '2026-09-16T00:00:00.000Z'
created: '2026-06-16T00:00:00.000Z'
'SNYK-JS-JSYAML-17342520':
- '* > js-yaml@3.14.2':
reason: 'js-yaml 3.x→4.x is a breaking major change for all transitive consumers (gray-matter/Docusaurus chain). js-yaml@4.1.1 patched to 4.2.0 via pnpm override; 3.x path has no safe upgrade.'
expires: '2026-09-16T00:00:00.000Z'
created: '2026-06-16T00:00:00.000Z'
- '* > js-yaml':
reason: 'Transitive in Docusaurus build tooling (gray-matter pins js-yaml@3.x; fix is in 4.2.0, a major bump blocked by gray-matter). Build-time parsing of repository-controlled input only.'
reason: 'Transitive via gray-matter (Docusaurus), which pins js-yaml 3.x and calls yaml.safeLoad — removed in js-yaml 4, so the 4.x-only fix breaks the docs build. 3.x is pinned to 3.15.0 (fixes SNYK-JS-JSYAML-17900054); this older advisory has no 3.x fix. Build-time parsing of repository-controlled input only.'
expires: '2026-09-18T00:00:00.000Z'
created: '2026-06-18T00:00:00.000Z'
'SNYK-JS-OPENTELEMETRYCORE-17373280':
Expand All @@ -173,3 +169,8 @@ ignore:
reason: 'Transitive in Docusaurus serve-handler for local static docs serving. The fixed brace-expansion 5.x line requires minimatch 10, whose named-export API is incompatible with serve-handler 6.1.7.'
expires: '2026-09-30T00:00:00.000Z'
created: '2026-06-30T00:00:00.000Z'
'SNYK-JS-OPENTELEMETRYPROPAGATORJAEGER-17901201':
- '* > @opentelemetry/propagator-jaeger@<=1.30.1':
reason: 'The transitive dependency of @opentelemetry does not have a fixed upgrade path. Accepted temporarily until upgrade paths are made available.'
expires: '2026-07-18T00:00:00.000Z'
created: '2026-06-18T00:00:00.000Z'
13 changes: 13 additions & 0 deletions apps/ui-staff/mock-oidc.users.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,18 @@
"tid": "test-staff-tenant-id",
"roles": ["Staff.CaseManager"]
}
},
{
"username": "tech.admin@ownercommunity.onmicrosoft.com",
"sub": "10000000-0000-4000-8000-000000000002",
"password": "password",
"oidcConfigName": "staff-user",
"claims": {
"email": "tech.admin@ownercommunity.onmicrosoft.com",
"given_name": "Tech",
"family_name": "Admin",
"tid": "test-staff-tenant-id",
"roles": ["Staff.TechAdmin"]
}
}
]
12 changes: 12 additions & 0 deletions packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,17 @@ export async function resolve(specifier: string, context: AssetLoaderResolveCont
}
}

// Redirect @apollo/client bare and directory subpath imports to their ESM
// entry points. The package's legacy `main` fields point at CJS bundles
// whose named exports Node cannot statically detect.
if (specifier === '@apollo/client' || (specifier.startsWith('@apollo/client/') && !/\.[cm]?js$/.test(specifier))) {
const redirected = specifier === '@apollo/client' ? '@apollo/client/index.js' : `${specifier}/index.js`;
try {
return await nextResolve(redirected, context);
} catch {
// fall through to default
}
}

return nextResolve(specifier, context);
}
3 changes: 3 additions & 0 deletions packages/cellix/serenity-framework/src/dom/css-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Side-effect stylesheet imports (handled by the asset loader at runtime).
// No exports: plain .css files can only be imported for their side effects.
declare module '*.css' {}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export class DomElementHandle implements ElementHandle {
if (this.element) {
const element = this.element;
act(() => {
// Fire the full pointer sequence: some widgets (e.g. antd Select)
// open on mousedown rather than click.
fireEvent.mouseDown(element);
fireEvent.mouseUp(element);
fireEvent.click(element);
});
}
Expand All @@ -94,6 +98,20 @@ export class DomElementHandle implements ElementHandle {
return Promise.resolve(this.element?.getAttribute(name) ?? null);
}

inputValue(): Promise<string | null> {
if (this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLSelectElement) {
return Promise.resolve(this.element.value);
}
return Promise.resolve(null);
}

isChecked(): Promise<boolean> {
if (this.element instanceof HTMLInputElement) {
return Promise.resolve(this.element.checked);
}
return Promise.resolve(false);
}

isVisible(): Promise<boolean> {
return Promise.resolve(this.element !== null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ export class PlaywrightElementHandle implements ElementHandle {
return this.locator.getAttribute(name);
}

async inputValue(): Promise<string | null> {
try {
return await this.locator.inputValue();
} catch {
// Match DomElementHandle: non-input elements yield null instead of throwing.
return null;
}
}

async isChecked(): Promise<boolean> {
try {
return await this.locator.isChecked();
} catch {
// Match DomElementHandle: non-checkbox elements yield false instead of throwing.
return false;
}
}

isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
Expand Down Expand Up @@ -81,7 +99,7 @@ export class PlaywrightPageAdapter implements PageAdapter {
}

locator(selector: string): ElementHandle {
return new PlaywrightElementHandle(this.page.locator(selector));
return new PlaywrightElementHandle(this.page.locator(selector).first());
}

async locatorAll(selector: string): Promise<ElementHandle[]> {
Expand Down
16 changes: 16 additions & 0 deletions packages/cellix/serenity-framework/src/pages/page-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ export interface ElementHandle {
/** Read an element attribute, or `null` when the attribute is missing. */
getAttribute(name: string): Promise<string | null>;

/**
* Read the current value of an input-like control.
*
* Adapters must resolve to `null` (rather than throw) when the element is not
* an input-like control, so page objects behave identically across adapters.
*/
inputValue(): Promise<string | null>;

/**
* Return whether a checkbox-like control is currently checked.
*
* Adapters must resolve to `false` (rather than throw) when the element is not
* a checkbox-like control, so page objects behave identically across adapters.
*/
isChecked(): Promise<boolean>;

/** Return whether the element is currently visible to the adapter runtime. */
isVisible(): Promise<boolean>;

Expand Down
1 change: 1 addition & 0 deletions packages/ocom-verification/acceptance-api/cucumber.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isAgent } from 'std-env';

export default {
paths: ['../verification-shared/src/scenarios/**/*.feature'],
tags: 'not @ui-only and not @e2e-only and not @skip-api',
import: ['src/world.ts', 'src/step-definitions/index.ts'],
format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'],
formatOptions: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Input details used when creating a staff role through the API. */
export interface StaffRoleDetails {
roleName: string;
enterpriseAppRole?: string | undefined;
permissions?: Record<string, boolean> | undefined;
}

/** Scenario-local staff-role state shared between tasks and questions via actor notes. */
export interface StaffRoleNotes {
lastStaffRoleStatus: string;
lastStaffRoleId: string;
lastStaffRoleName: string;
lastStaffRoleError: string;
listedStaffRoleNames: string[];
viewedStaffRoleId: string;
viewedStaffRoleName: string;
viewedStaffRoleEnterpriseAppRole: string;
baselineStaffRoleCount: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import type { StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts';
import { StaffRolesList } from './staff-roles-list.ts';

/**
* Question that finds a staff role by its role name through the API.
* Answers `undefined` when no staff role with that name exists.
*/
export class StaffRoleNamed extends Question<Promise<StaffRoleResult | undefined>> {
static called(roleName: string): StaffRoleNamed {
return new StaffRoleNamed(roleName);
}

private constructor(private readonly roleName: string) {
super(`the staff role named "${roleName}"`);
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<StaffRoleResult | undefined> {
const roles = await actor.answer(StaffRolesList.displayed());
return roles.find((role) => role.roleName === this.roleName);
}

override toString = () => `the staff role named "${this.roleName}"`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { notes, Question } from '@serenity-js/core';
import type { StaffRoleNotes } from '../notes/staff-role-notes.ts';

/** Question that reads the status of the last staff-role mutation from actor notes. */
export const StaffRoleStatus = {
of: () =>
Question.about('the staff role mutation status', async (actor) => {
try {
return await actor.answer(notes<StaffRoleNotes>().get('lastStaffRoleStatus'));
} catch {
return undefined;
}
}),
} as const;

/** Question that reads the error captured for the last staff-role action from actor notes. */
export const StaffRoleError = {
captured: () =>
Question.about('the captured staff role error', async (actor) => {
try {
return await actor.answer(notes<StaffRoleNotes>().get('lastStaffRoleError'));
} catch {
return undefined;
}
}),
} as const;

/** Question that reads the staff role names recorded when the actor viewed the list. */
export const ListedStaffRoleNames = {
recorded: () =>
Question.about('the recorded staff role names', async (actor) => {
try {
return await actor.answer(notes<StaffRoleNotes>().get('listedStaffRoleNames'));
} catch {
return undefined;
}
}),
} as const;

/** Question that reads the staff role count captured before a create attempt. */
export const BaselineStaffRoleCount = {
recorded: () =>
Question.about('the baseline staff role count', async (actor) => {
try {
return await actor.answer(notes<StaffRoleNotes>().get('baselineStaffRoleCount'));
} catch {
return undefined;
}
}),
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { STAFF_ROLE_PERMISSION_GROUP_BY_KEY } from '../../../shared/graphql/staff-role-operations.ts';
import { StaffRoleNamed } from './staff-role-named.ts';

/**
* Question that answers whether a named staff role has a permission granted,
* read live from the API.
*/
export class StaffRolePermission extends Question<Promise<boolean>> {
static granted(roleName: string, permissionKey: string): StaffRolePermission {
return new StaffRolePermission(roleName, permissionKey);
}

private constructor(
private readonly roleName: string,
private readonly permissionKey: string,
) {
super(`whether the staff role "${roleName}" has the permission "${permissionKey}" granted`);
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<boolean> {
const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[this.permissionKey];
if (!group) {
throw new Error(`Unknown staff role permission "${this.permissionKey}"`);
}

const role = await actor.answer(StaffRoleNamed.called(this.roleName));
if (!role) {
throw new Error(`Staff role "${this.roleName}" was not found`);
}

return role.permissions[group]?.[this.permissionKey] === true;
}

override toString = () => `whether the staff role "${this.roleName}" has the permission "${this.permissionKey}" granted`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql';
import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { STAFF_ROLES_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts';

/**
* Question that reads the staff roles currently visible to the actor from the API.
*/
export class StaffRolesList extends Question<Promise<StaffRoleResult[]>> {
constructor() {
super('the staff roles list');
}

static displayed(): StaffRolesList {
return new StaffRolesList();
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<StaffRoleResult[]> {
const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_ROLES_QUERY);
return response.data['staffRoles'] as StaffRoleResult[];
}

override toString = () => 'the staff roles list';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql';
import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core';
import { STAFF_USERS_QUERY, type StaffUserResult } from '../../../shared/graphql/staff-role-operations.ts';

/**
* Question that finds a staff user by display name through the API.
* Fails with a diagnostic error when the staff user does not exist.
*/
export class StaffUserNamed extends Question<Promise<StaffUserResult>> {
static called(displayName: string): StaffUserNamed {
return new StaffUserNamed(displayName);
}

private constructor(private readonly displayName: string) {
super(`the staff user "${displayName}"`);
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<StaffUserResult> {
const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_USERS_QUERY);
const staffUsers = response.data['staffUsers'] as StaffUserResult[];
const staffUser = staffUsers.find((user) => user.displayName === this.displayName);
if (!staffUser) {
throw new Error(`Staff user "${this.displayName}" was not found`);
}
return staffUser;
}

override toString = () => `the staff user "${this.displayName}"`;
}
Loading
Loading