Skip to content

Commit 44721f0

Browse files
Merge pull request #83 from withhaibun/feature/new-steppers
Add JSON extraction and shadow DOM validation steppers
2 parents d56531b + 6de797e commit 44721f0

File tree

4 files changed

+123
-1
lines changed

4 files changed

+123
-1
lines changed

modules/web-playwright/src/interactionSteps.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,51 @@ export const interactionSteps = (wp: WebPlaywright): TStepperSteps => ({
7575
gwta: `wait for {target: ${DOMAIN_STRING_OR_PAGE_LOCATOR}}`,
7676
action: async ({ target }: { target: string }, featureStep: TFeatureStep) => {
7777
try {
78+
// Check if we're being called from within inElement with a shadow DOM context
79+
if (wp.inContainerSelector) {
80+
try {
81+
// Get the actual Page object (not through withPage which might return a Locator)
82+
const page = await wp.getPage();
83+
// Assume the container is a shadow DOM host - wait for element in shadow root
84+
await page.waitForFunction(
85+
({ containerSel, innerSel }) => {
86+
const host = document.querySelector(containerSel);
87+
if (!host?.shadowRoot) return false;
88+
89+
const element = host.shadowRoot.querySelector(innerSel);
90+
if (!element) return false;
91+
92+
// Use getBoundingClientRect to check if element has dimensions
93+
const rect = element.getBoundingClientRect();
94+
if (rect.width === 0 || rect.height === 0) return false;
95+
96+
// Check computed styles for common hiding methods
97+
const computed = window.getComputedStyle(element);
98+
if (computed.display === 'none' || computed.visibility === 'hidden' || computed.opacity === '0') return false;
99+
100+
// Check if element is behind other layers (negative z-index parent)
101+
let current = element.parentElement;
102+
while (current) {
103+
const style = window.getComputedStyle(current);
104+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
105+
const zIndex = parseInt(style.zIndex);
106+
if (!isNaN(zIndex) && zIndex < 0) return false;
107+
current = current.parentElement;
108+
}
109+
110+
return true;
111+
},
112+
{ containerSel: wp.inContainerSelector, innerSel: target },
113+
{ timeout: 30000 }
114+
);
115+
return OK;
116+
} catch (e) {
117+
// Shadow DOM approach failed, return error
118+
return actionNotOK(`Did not find ${target} in shadow DOM: ${e}`);
119+
}
120+
}
121+
122+
// Regular wait (not in shadow DOM)
78123
await wp.withPage(async (page: Page) => await locateByDomain(page, featureStep, 'target').waitFor());
79124
return OK;
80125
} catch (e) {
@@ -221,12 +266,14 @@ export const interactionSteps = (wp: WebPlaywright): TStepperSteps => ({
221266
},
222267
inElement: {
223268
gwta: `in {container: ${DOMAIN_STRING_OR_PAGE_LOCATOR}}, {what: ${DOMAIN_STATEMENT}}`,
224-
action: async ({ what }: { container: string; what: TFeatureStep[] }, featureStep: TFeatureStep) => {
269+
action: async ({ container, what }: { container: string; what: TFeatureStep[] }, featureStep: TFeatureStep) => {
225270
return await wp.withPage(async (page: Page) => {
226271
const containerLocator = locateByDomain(page, featureStep, 'container');
227272
wp.inContainer = containerLocator;
273+
wp.inContainerSelector = container; // Store the selector string for shadow DOM detection
228274
const whenResult = await doExecuteFeatureSteps(what, [wp], wp.getWorld(), ExecMode.CYCLES);
229275
wp.inContainer = undefined;
276+
wp.inContainerSelector = undefined;
230277
return whenResult;
231278
});
232279
},
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { actionNotOK } from '@haibun/core/lib/util/index.js';
2+
import { OK, Origin, TFeatureStep } from '@haibun/core/lib/defs.js';
3+
import { DOMAIN_STRING } from '@haibun/core/lib/domain-types.js';
4+
import WebPlaywright from './web-playwright.js';
5+
import { TAnyFixme } from '@haibun/core/lib/fixme.js';
6+
7+
const getTargetFromResponse = (json: TAnyFixme, index: number): TAnyFixme => {
8+
return Array.isArray(json) ? json[index] : json;
9+
};
10+
11+
const parseIndex = (indexStr: string): number => {
12+
// Handle ordinal patterns like "1st", "2nd", "3rd", "4th", "14th", etc.
13+
const ordinalMatch = indexStr.match(/^(\d+)(?:st|nd|rd|th)$/i);
14+
if (ordinalMatch) {
15+
return parseInt(ordinalMatch[1], 10) - 1; // Convert 1-based to 0-based
16+
}
17+
18+
// Try parsing as a direct number (0-based index)
19+
const num = parseInt(indexStr, 10);
20+
if (!isNaN(num)) {
21+
return num;
22+
}
23+
24+
return 0; // Default to first (index 0)
25+
};
26+
27+
const valueToString = (value: TAnyFixme): string => {
28+
if (typeof value === 'string') {
29+
return value;
30+
}
31+
if (typeof value === 'number' || typeof value === 'boolean') {
32+
return String(value);
33+
}
34+
if (value === null || value === undefined) {
35+
return String(value);
36+
}
37+
return JSON.stringify(value);
38+
};
39+
40+
export const jsonExtractSteps = (webPlaywright: WebPlaywright) => ({
41+
extractPropertyFromResponseJson: {
42+
gwta: `extract property {property} from {ordinal} item in JSON response into {variable}`,
43+
action: ({ property, ordinal = '1st', variable }: { property: string; ordinal?: string; variable: string }, featureStep: TFeatureStep) => {
44+
const lastResponse = webPlaywright.getLastResponse();
45+
46+
if (!lastResponse?.json) {
47+
return actionNotOK(`No JSON response available. Make an HTTP request first.`);
48+
}
49+
50+
const index = parseIndex(ordinal);
51+
const target = getTargetFromResponse(lastResponse.json, index);
52+
53+
if (!target) {
54+
return actionNotOK(`Response is empty or invalid.`);
55+
}
56+
57+
const value = target[property];
58+
const valueStr = valueToString(value);
59+
60+
webPlaywright.getWorld().shared.set(
61+
{ term: variable, value: valueStr, domain: DOMAIN_STRING, origin: Origin.var },
62+
{ in: featureStep.in, seq: featureStep.seqPath, when: `jsonExtractSteps.extractPropertyFromResponseJson` }
63+
);
64+
65+
webPlaywright.getWorld().logger.info(`Extracted ${property}='${valueStr}' from ${ordinal} item into variable '${variable}'`);
66+
67+
return OK;
68+
},
69+
},
70+
});

modules/web-playwright/src/rest-playwright.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export const restSteps = (webPlaywright: WebPlaywright) => ({
165165
},
166166
},
167167
restEndpointRequestWithPayload: {
168+
precludes: ["WebPlaywright.restEndpointRequest"],
168169
gwta: `make an ${'HTTP'} {method} to {endpoint} with {payload}`,
169170
action: async ({ method, endpoint, payload }: { method: string; endpoint: string; payload: string }, featureStep) => {
170171
method = method.toLowerCase();

modules/web-playwright/src/web-playwright.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AStepper, IHasCycles, IHasOptions } from '@haibun/core/lib/astepper.js'
1515
import { cycles } from './cycles.js';
1616
import { interactionSteps } from './interactionSteps.js';
1717
import { restSteps, TCapturedResponse } from './rest-playwright.js';
18+
import { jsonExtractSteps } from './jsonExtractSteps.js';
1819
import { TwinPage } from './twin-page.js';
1920

2021
export const WEB_PAGE = 'webpage';
@@ -23,6 +24,7 @@ export const WEB_PAGE = 'webpage';
2324
*
2425
* @see {@link interactionSteps} for interaction steps
2526
* @see {@link restSteps} for rest steps
27+
* @see {@link jsonExtractSteps} for JSON extraction steps
2628
*/
2729

2830

@@ -99,6 +101,7 @@ export class WebPlaywright extends AStepper implements IHasOptions, IHasCycles {
99101
expectedDownload: Promise<Download>;
100102
headless: boolean;
101103
inContainer: Locator;
104+
inContainerSelector: string;
102105

103106
async setWorld(world: TWorld, steppers: AStepper[]) {
104107
await super.setWorld(world, steppers);
@@ -197,6 +200,7 @@ export class WebPlaywright extends AStepper implements IHasOptions, IHasCycles {
197200
steps = {
198201
...restSteps(this),
199202
...interactionSteps(this),
203+
...jsonExtractSteps(this),
200204
};
201205
setBrowser(browser: string) {
202206
this.factoryOptions.type = browser as unknown as TBrowserTypes;

0 commit comments

Comments
 (0)