Skip to content
Merged
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
49 changes: 48 additions & 1 deletion modules/web-playwright/src/interactionSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,51 @@ export const interactionSteps = (wp: WebPlaywright): TStepperSteps => ({
gwta: `wait for {target: ${DOMAIN_STRING_OR_PAGE_LOCATOR}}`,
action: async ({ target }: { target: string }, featureStep: TFeatureStep) => {
try {
// Check if we're being called from within inElement with a shadow DOM context
if (wp.inContainerSelector) {
try {
// Get the actual Page object (not through withPage which might return a Locator)
const page = await wp.getPage();
// Assume the container is a shadow DOM host - wait for element in shadow root
await page.waitForFunction(
({ containerSel, innerSel }) => {
const host = document.querySelector(containerSel);
if (!host?.shadowRoot) return false;

const element = host.shadowRoot.querySelector(innerSel);
if (!element) return false;

// Use getBoundingClientRect to check if element has dimensions
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;

// Check computed styles for common hiding methods
const computed = window.getComputedStyle(element);
if (computed.display === 'none' || computed.visibility === 'hidden' || computed.opacity === '0') return false;

// Check if element is behind other layers (negative z-index parent)
let current = element.parentElement;
while (current) {
const style = window.getComputedStyle(current);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const zIndex = parseInt(style.zIndex);
if (!isNaN(zIndex) && zIndex < 0) return false;
current = current.parentElement;
}

return true;
},
{ containerSel: wp.inContainerSelector, innerSel: target },
{ timeout: 30000 }
);
return OK;
} catch (e) {
// Shadow DOM approach failed, return error
return actionNotOK(`Did not find ${target} in shadow DOM: ${e}`);
}
}

// Regular wait (not in shadow DOM)
await wp.withPage(async (page: Page) => await locateByDomain(page, featureStep, 'target').waitFor());
return OK;
} catch (e) {
Expand Down Expand Up @@ -221,12 +266,14 @@ export const interactionSteps = (wp: WebPlaywright): TStepperSteps => ({
},
inElement: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to use the existing click function with buttons. We want to minimize the number of stepper steps to avoid cognitive overload for humans, and MCP works better with fewer too. Though at some point, will need to review the steppers and organize them better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, was definitely redundant. Addressed in the new commit.

gwta: `in {container: ${DOMAIN_STRING_OR_PAGE_LOCATOR}}, {what: ${DOMAIN_STATEMENT}}`,
action: async ({ what }: { container: string; what: TFeatureStep[] }, featureStep: TFeatureStep) => {
action: async ({ container, what }: { container: string; what: TFeatureStep[] }, featureStep: TFeatureStep) => {
return await wp.withPage(async (page: Page) => {
const containerLocator = locateByDomain(page, featureStep, 'container');
wp.inContainer = containerLocator;
wp.inContainerSelector = container; // Store the selector string for shadow DOM detection
const whenResult = await doExecuteFeatureSteps(what, [wp], wp.getWorld(), ExecMode.CYCLES);
wp.inContainer = undefined;
wp.inContainerSelector = undefined;
return whenResult;
});
},
Expand Down
70 changes: 70 additions & 0 deletions modules/web-playwright/src/jsonExtractSteps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { actionNotOK } from '@haibun/core/lib/util/index.js';
import { OK, Origin, TFeatureStep } from '@haibun/core/lib/defs.js';
import { DOMAIN_STRING } from '@haibun/core/lib/domain-types.js';
import WebPlaywright from './web-playwright.js';
import { TAnyFixme } from '@haibun/core/lib/fixme.js';

const getTargetFromResponse = (json: TAnyFixme, index: number): TAnyFixme => {
return Array.isArray(json) ? json[index] : json;
};

const parseIndex = (indexStr: string): number => {
// Handle ordinal patterns like "1st", "2nd", "3rd", "4th", "14th", etc.
const ordinalMatch = indexStr.match(/^(\d+)(?:st|nd|rd|th)$/i);
if (ordinalMatch) {
return parseInt(ordinalMatch[1], 10) - 1; // Convert 1-based to 0-based
}

// Try parsing as a direct number (0-based index)
const num = parseInt(indexStr, 10);
if (!isNaN(num)) {
return num;
}

return 0; // Default to first (index 0)
};

const valueToString = (value: TAnyFixme): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value === null || value === undefined) {
return String(value);
}
return JSON.stringify(value);
};

export const jsonExtractSteps = (webPlaywright: WebPlaywright) => ({
extractPropertyFromResponseJson: {
gwta: `extract property {property} from {ordinal} item in JSON response into {variable}`,
action: ({ property, ordinal = '1st', variable }: { property: string; ordinal?: string; variable: string }, featureStep: TFeatureStep) => {
const lastResponse = webPlaywright.getLastResponse();

if (!lastResponse?.json) {
return actionNotOK(`No JSON response available. Make an HTTP request first.`);
}

const index = parseIndex(ordinal);
const target = getTargetFromResponse(lastResponse.json, index);

if (!target) {
return actionNotOK(`Response is empty or invalid.`);
}

const value = target[property];
const valueStr = valueToString(value);

webPlaywright.getWorld().shared.set(
{ term: variable, value: valueStr, domain: DOMAIN_STRING, origin: Origin.var },
{ in: featureStep.in, seq: featureStep.seqPath, when: `jsonExtractSteps.extractPropertyFromResponseJson` }
);

webPlaywright.getWorld().logger.info(`Extracted ${property}='${valueStr}' from ${ordinal} item into variable '${variable}'`);

return OK;
},
},
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM if it is fit to purpose.

1 change: 1 addition & 0 deletions modules/web-playwright/src/rest-playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export const restSteps = (webPlaywright: WebPlaywright) => ({
},
},
restEndpointRequestWithPayload: {
precludes: ["WebPlaywright.restEndpointRequest"],
gwta: `make an ${'HTTP'} {method} to {endpoint} with {payload}`,
action: async ({ method, endpoint, payload }: { method: string; endpoint: string; payload: string }, featureStep) => {
method = method.toLowerCase();
Expand Down
4 changes: 4 additions & 0 deletions modules/web-playwright/src/web-playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AStepper, IHasCycles, IHasOptions } from '@haibun/core/lib/astepper.js'
import { cycles } from './cycles.js';
import { interactionSteps } from './interactionSteps.js';
import { restSteps, TCapturedResponse } from './rest-playwright.js';
import { jsonExtractSteps } from './jsonExtractSteps.js';
import { TwinPage } from './twin-page.js';

export const WEB_PAGE = 'webpage';
Expand All @@ -23,6 +24,7 @@ export const WEB_PAGE = 'webpage';
*
* @see {@link interactionSteps} for interaction steps
* @see {@link restSteps} for rest steps
* @see {@link jsonExtractSteps} for JSON extraction steps
*/


Expand Down Expand Up @@ -99,6 +101,7 @@ export class WebPlaywright extends AStepper implements IHasOptions, IHasCycles {
expectedDownload: Promise<Download>;
headless: boolean;
inContainer: Locator;
inContainerSelector: string;

async setWorld(world: TWorld, steppers: AStepper[]) {
await super.setWorld(world, steppers);
Expand Down Expand Up @@ -197,6 +200,7 @@ export class WebPlaywright extends AStepper implements IHasOptions, IHasCycles {
steps = {
...restSteps(this),
...interactionSteps(this),
...jsonExtractSteps(this),
};
setBrowser(browser: string) {
this.factoryOptions.type = browser as unknown as TBrowserTypes;
Expand Down