@sun-asterisk/sungen 1.0.18 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/cli/commands/live-scan-command.d.ts +9 -0
- package/dist/cli/commands/live-scan-command.d.ts.map +1 -0
- package/dist/cli/commands/live-scan-command.js +72 -0
- package/dist/cli/commands/live-scan-command.js.map +1 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/live-scanner/config-reader.d.ts +10 -0
- package/dist/core/live-scanner/config-reader.d.ts.map +1 -0
- package/dist/core/live-scanner/config-reader.js +87 -0
- package/dist/core/live-scanner/config-reader.js.map +1 -0
- package/dist/core/live-scanner/element-finder.d.ts +20 -0
- package/dist/core/live-scanner/element-finder.d.ts.map +1 -0
- package/dist/core/live-scanner/element-finder.js +289 -0
- package/dist/core/live-scanner/element-finder.js.map +1 -0
- package/dist/core/live-scanner/index.d.ts +8 -0
- package/dist/core/live-scanner/index.d.ts.map +1 -0
- package/dist/core/live-scanner/index.js +33 -0
- package/dist/core/live-scanner/index.js.map +1 -0
- package/dist/core/live-scanner/matrix-reader.d.ts +17 -0
- package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -0
- package/dist/core/live-scanner/matrix-reader.js +97 -0
- package/dist/core/live-scanner/matrix-reader.js.map +1 -0
- package/dist/core/live-scanner/matrix-writer.d.ts +7 -0
- package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -0
- package/dist/core/live-scanner/matrix-writer.js +92 -0
- package/dist/core/live-scanner/matrix-writer.js.map +1 -0
- package/dist/core/live-scanner/role-fallback.d.ts +15 -0
- package/dist/core/live-scanner/role-fallback.d.ts.map +1 -0
- package/dist/core/live-scanner/role-fallback.js +45 -0
- package/dist/core/live-scanner/role-fallback.js.map +1 -0
- package/dist/core/live-scanner/scanner.d.ts +13 -0
- package/dist/core/live-scanner/scanner.d.ts.map +1 -0
- package/dist/core/live-scanner/scanner.js +225 -0
- package/dist/core/live-scanner/scanner.js.map +1 -0
- package/dist/core/live-scanner/step-replayer.d.ts +26 -0
- package/dist/core/live-scanner/step-replayer.d.ts.map +1 -0
- package/dist/core/live-scanner/step-replayer.js +184 -0
- package/dist/core/live-scanner/step-replayer.js.map +1 -0
- package/dist/core/live-scanner/types.d.ts +50 -0
- package/dist/core/live-scanner/types.d.ts.map +1 -0
- package/dist/core/live-scanner/types.js +14 -0
- package/dist/core/live-scanner/types.js.map +1 -0
- package/dist/generators/cli.js +1 -1
- package/dist/generators/scaffold-generator/index.d.ts +12 -1
- package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
- package/dist/generators/scaffold-generator/index.js +118 -2
- package/dist/generators/scaffold-generator/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/assertion-patterns.js +6 -3
- package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +3 -5
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +18 -7
- package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +6 -0
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/input/cli-adapter.d.ts +4 -0
- package/dist/input/cli-adapter.d.ts.map +1 -1
- package/dist/input/cli-adapter.js +16 -1
- package/dist/input/cli-adapter.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/live-scan-command.ts +79 -0
- package/src/cli/index.ts +35 -2
- package/src/config/default.config.yaml +6 -0
- package/src/core/live-scanner/config-reader.ts +57 -0
- package/src/core/live-scanner/element-finder.ts +333 -0
- package/src/core/live-scanner/index.ts +7 -0
- package/src/core/live-scanner/matrix-reader.ts +70 -0
- package/src/core/live-scanner/matrix-writer.ts +66 -0
- package/src/core/live-scanner/role-fallback.ts +43 -0
- package/src/core/live-scanner/scanner.ts +231 -0
- package/src/core/live-scanner/step-replayer.ts +219 -0
- package/src/core/live-scanner/types.ts +56 -0
- package/src/generators/cli.ts +1 -1
- package/src/generators/scaffold-generator/index.ts +127 -4
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
- package/src/generators/test-generator/patterns/assertion-patterns.ts +6 -3
- package/src/generators/test-generator/template-engine.ts +3 -4
- package/src/generators/test-generator/utils/data-resolver.ts +20 -9
- package/src/generators/test-generator/utils/selector-resolver.ts +8 -0
- package/src/input/cli-adapter.ts +17 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Scan CLI Command
|
|
3
|
+
* Launches a browser, replays Gherkin steps, and produces .live-scan.yaml matrix files.
|
|
4
|
+
* Auto-detects baseURL from playwright.config.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import { RuntimeConfig } from '../../config/config-schema';
|
|
9
|
+
import { LiveScanner } from '../../core/live-scanner';
|
|
10
|
+
|
|
11
|
+
export function createLiveScanCommand(config: RuntimeConfig): Command {
|
|
12
|
+
const cmd = new Command('live-scan');
|
|
13
|
+
|
|
14
|
+
cmd
|
|
15
|
+
.description('Scan a live site to discover real selectors for Gherkin element references')
|
|
16
|
+
.requiredOption('-s, --screen <name>', 'Screen name to scan')
|
|
17
|
+
.option('--url <baseUrl>', 'Override base URL (default: read from playwright.config.ts)')
|
|
18
|
+
.option('--headed', 'Launch browser in headed (visible) mode', false)
|
|
19
|
+
.option('--auth-dir <path>', 'Directory containing auth storage state files', 'specs/.auth')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
console.log('\n🔍 Sungen Live Scanner\n');
|
|
23
|
+
console.log(`📍 Screen: ${options.screen}`);
|
|
24
|
+
console.log(`🖥️ Mode: ${options.headed ? 'headed' : 'headless'}`);
|
|
25
|
+
|
|
26
|
+
const scanner = new LiveScanner({
|
|
27
|
+
baseUrl: options.url, // undefined = auto-detect from playwright.config.ts
|
|
28
|
+
screenName: options.screen,
|
|
29
|
+
screensDir: 'qa/screens',
|
|
30
|
+
headed: options.headed,
|
|
31
|
+
authDir: options.authDir,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const results = await scanner.scan();
|
|
35
|
+
|
|
36
|
+
// Print final summary
|
|
37
|
+
console.log('\n📊 SCAN SUMMARY\n');
|
|
38
|
+
|
|
39
|
+
let totalElements = 0;
|
|
40
|
+
let totalResolved = 0;
|
|
41
|
+
let totalWarnings = 0;
|
|
42
|
+
let totalUnresolved = 0;
|
|
43
|
+
|
|
44
|
+
for (const result of results) {
|
|
45
|
+
for (const scenario of Object.values(result.scenarios)) {
|
|
46
|
+
for (const element of Object.values(scenario.elements)) {
|
|
47
|
+
totalElements++;
|
|
48
|
+
if (element.matchMethod === 'unresolved') {
|
|
49
|
+
totalUnresolved++;
|
|
50
|
+
} else {
|
|
51
|
+
totalResolved++;
|
|
52
|
+
if (element.warning) totalWarnings++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`Total Elements: ${totalElements}`);
|
|
59
|
+
console.log(`Resolved: ${totalResolved}`);
|
|
60
|
+
console.log(`Warnings: ${totalWarnings}`);
|
|
61
|
+
console.log(`Unresolved: ${totalUnresolved}`);
|
|
62
|
+
|
|
63
|
+
if (totalUnresolved > 0) {
|
|
64
|
+
console.log(`\n⚠️ ${totalUnresolved} element(s) could not be found on the live site`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (totalResolved > 0) {
|
|
68
|
+
console.log(`\n✅ Live scan complete. Run 'sungen map --screen ${options.screen}' to generate selectors.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log('');
|
|
72
|
+
} catch (error: any) {
|
|
73
|
+
console.error('\n❌ Live scan failed:', error.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return cmd;
|
|
79
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -166,13 +166,29 @@ async function main() {
|
|
|
166
166
|
// Process screen folder: qa/screens/<screen>/features/
|
|
167
167
|
const screensDir = options.output || 'qa/screens';
|
|
168
168
|
const forceOverwrite = cliOptions.force || false;
|
|
169
|
-
|
|
169
|
+
|
|
170
|
+
// Run live-scan first if --scan-live flag is set
|
|
171
|
+
if (options.scanLive) {
|
|
172
|
+
const { LiveScanner } = require('../core/live-scanner');
|
|
173
|
+
console.log('🔍 Running live-scan before mapping...\n');
|
|
174
|
+
|
|
175
|
+
const scanner = new LiveScanner({
|
|
176
|
+
screenName: options.screen,
|
|
177
|
+
screensDir,
|
|
178
|
+
headed: options.headed,
|
|
179
|
+
authDir: options.authDir,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await scanner.scan();
|
|
183
|
+
console.log('');
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
if (forceOverwrite) {
|
|
171
187
|
console.log(`Mapping screen: ${options.screen} (force: selectors will be overwritten)\n`);
|
|
172
188
|
} else {
|
|
173
189
|
console.log(`Mapping screen: ${options.screen}\n`);
|
|
174
190
|
}
|
|
175
|
-
|
|
191
|
+
|
|
176
192
|
const results = generator.processScreen(options.screen, screensDir, forceOverwrite);
|
|
177
193
|
|
|
178
194
|
for (const result of results) {
|
|
@@ -326,6 +342,23 @@ async function main() {
|
|
|
326
342
|
}
|
|
327
343
|
});
|
|
328
344
|
|
|
345
|
+
const liveScanCmd = adapter.addLiveScanCommand(program);
|
|
346
|
+
liveScanCmd.action(async (options) => {
|
|
347
|
+
try {
|
|
348
|
+
const cliOptions = {
|
|
349
|
+
...program.opts(),
|
|
350
|
+
...options
|
|
351
|
+
};
|
|
352
|
+
const config = new ConfigLoader().load(cliOptions);
|
|
353
|
+
const { createLiveScanCommand } = require('./commands/live-scan-command');
|
|
354
|
+
|
|
355
|
+
await createLiveScanCommand(config).parseAsync(['node', 'sungen', ...process.argv.slice(3)]);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error('❌ Live scan failed:', error);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
329
362
|
const addScreenCmd = adapter.addAddScreenCommand(program);
|
|
330
363
|
addScreenCmd.action(async (options) => {
|
|
331
364
|
try {
|
|
@@ -72,6 +72,12 @@ testGenerator:
|
|
|
72
72
|
enableAIFallback: false # Enable AI for unknown patterns (--ai-mapper flag)
|
|
73
73
|
dictionaryPath: "qa/executor/playwright/dictionary.yaml"
|
|
74
74
|
|
|
75
|
+
# Live scan configuration
|
|
76
|
+
liveScan:
|
|
77
|
+
baseUrl: "" # Base URL for live scanning (e.g., "http://localhost:3000")
|
|
78
|
+
headed: false # Launch browser in headed mode
|
|
79
|
+
authDir: "specs/.auth" # Directory containing auth storage state files
|
|
80
|
+
|
|
75
81
|
# Cache configuration
|
|
76
82
|
cache:
|
|
77
83
|
enabled: true
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Reader
|
|
3
|
+
* Extracts baseURL from playwright.config.ts in the project root.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read baseURL from playwright.config.ts by searching for the baseURL property.
|
|
11
|
+
* Searches from the given directory upward to find playwright.config.ts.
|
|
12
|
+
*/
|
|
13
|
+
export function readBaseUrlFromPlaywrightConfig(startDir: string = process.cwd()): string | null {
|
|
14
|
+
const configPath = findPlaywrightConfig(startDir);
|
|
15
|
+
if (!configPath) return null;
|
|
16
|
+
|
|
17
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
18
|
+
return extractBaseUrl(content);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findPlaywrightConfig(startDir: string): string | null {
|
|
22
|
+
const names = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
|
|
23
|
+
let dir = path.resolve(startDir);
|
|
24
|
+
|
|
25
|
+
// Search up to 5 levels up
|
|
26
|
+
for (let i = 0; i < 5; i++) {
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const configPath = path.join(dir, name);
|
|
29
|
+
if (fs.existsSync(configPath)) {
|
|
30
|
+
return configPath;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractBaseUrl(content: string): string | null {
|
|
42
|
+
// Match baseURL: 'value' or baseURL: "value" in the config
|
|
43
|
+
// Handles both direct assignment and inside use: { ... }
|
|
44
|
+
const patterns = [
|
|
45
|
+
/baseURL\s*:\s*['"]([^'"]+)['"]/,
|
|
46
|
+
/baseUrl\s*:\s*['"]([^'"]+)['"]/,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const pattern of patterns) {
|
|
50
|
+
const match = content.match(pattern);
|
|
51
|
+
if (match) {
|
|
52
|
+
return match[1];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element Finder
|
|
3
|
+
*
|
|
4
|
+
* Searches for elements using Playwright's own locator strategies in priority order:
|
|
5
|
+
* ① getByTestId — data-testid attribute
|
|
6
|
+
* ② getByRole — ARIA role + accessible name (exact match, then fallback roles)
|
|
7
|
+
* ③ getByLabel — associated <label> text
|
|
8
|
+
* ④ getByPlaceholder — placeholder attribute
|
|
9
|
+
* ⑤ getByText — visible text content
|
|
10
|
+
*
|
|
11
|
+
* The result records WHICH Playwright strategy found the element,
|
|
12
|
+
* so `map` can generate the correct locator code.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Page, Locator } from 'playwright';
|
|
16
|
+
import { getRoleFallbacks, isTextOnlyType } from './role-fallback';
|
|
17
|
+
import { LiveElement, MatchMethod, SelectorType, ElementContext } from './types';
|
|
18
|
+
|
|
19
|
+
interface FindResult {
|
|
20
|
+
locator: Locator;
|
|
21
|
+
selectorType: SelectorType;
|
|
22
|
+
selectorValue: string;
|
|
23
|
+
role: string;
|
|
24
|
+
matchMethod: MatchMethod;
|
|
25
|
+
warning: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find an element on the page using cascading Playwright locator strategies.
|
|
30
|
+
*/
|
|
31
|
+
export async function findElement(
|
|
32
|
+
page: Page,
|
|
33
|
+
gherkinRef: string,
|
|
34
|
+
gherkinType: string,
|
|
35
|
+
nth: number = 0
|
|
36
|
+
): Promise<LiveElement> {
|
|
37
|
+
const namePattern = new RegExp(escapeRegex(gherkinRef), 'i');
|
|
38
|
+
|
|
39
|
+
const result = await cascadingSearch(page, gherkinRef, gherkinType, namePattern, nth);
|
|
40
|
+
|
|
41
|
+
if (!result) {
|
|
42
|
+
return buildUnresolvedElement(gherkinRef, gherkinType);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const attrs = await extractElementAttributes(result.locator);
|
|
46
|
+
|
|
47
|
+
// Use Playwright's computed accessible name (includes alt text, aria-label, etc.)
|
|
48
|
+
// This matches what getByRole/getByText actually see, unlike textContent
|
|
49
|
+
let playwrightName = '';
|
|
50
|
+
try {
|
|
51
|
+
playwrightName = await result.locator.evaluate((el) => {
|
|
52
|
+
// computedRole/computedLabel not available in all browsers, fallback to aria
|
|
53
|
+
return (el as any).computedName
|
|
54
|
+
|| el.getAttribute('aria-label')
|
|
55
|
+
|| el.textContent?.trim()?.substring(0, 100)
|
|
56
|
+
|| '';
|
|
57
|
+
});
|
|
58
|
+
// Better approach: use Playwright's accessibility snapshot
|
|
59
|
+
const ariaSnapshot = await result.locator.ariaSnapshot();
|
|
60
|
+
// Parse accessible name from snapshot (format: "- role \"name\"" or "- role \"name\": ...")
|
|
61
|
+
const nameMatch = ariaSnapshot?.match(/"([^"]+)"/);
|
|
62
|
+
if (nameMatch) {
|
|
63
|
+
playwrightName = nameMatch[1];
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
playwrightName = attrs.accessibleName || gherkinRef;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const actualName = playwrightName || attrs.accessibleName || gherkinRef;
|
|
70
|
+
const isExactMatch = actualName.toLowerCase().trim() === gherkinRef.toLowerCase().trim();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
gherkinRef,
|
|
74
|
+
gherkinType,
|
|
75
|
+
selectorType: result.selectorType,
|
|
76
|
+
selectorValue: result.selectorValue,
|
|
77
|
+
role: result.role,
|
|
78
|
+
name: actualName,
|
|
79
|
+
testid: attrs.testid,
|
|
80
|
+
tag: attrs.tag,
|
|
81
|
+
placeholder: attrs.placeholder,
|
|
82
|
+
label: attrs.label,
|
|
83
|
+
context: 'page',
|
|
84
|
+
matchMethod: result.matchMethod,
|
|
85
|
+
exact: isExactMatch,
|
|
86
|
+
warning: result.warning,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function cascadingSearch(
|
|
91
|
+
page: Page,
|
|
92
|
+
gherkinRef: string,
|
|
93
|
+
gherkinType: string,
|
|
94
|
+
namePattern: RegExp,
|
|
95
|
+
nth: number
|
|
96
|
+
): Promise<FindResult | null> {
|
|
97
|
+
// ① getByTestId
|
|
98
|
+
const testidResult = await tryTestId(page, gherkinRef, nth);
|
|
99
|
+
if (testidResult) return testidResult;
|
|
100
|
+
|
|
101
|
+
// For text-only types (text, message, error), skip role and go to text
|
|
102
|
+
if (!isTextOnlyType(gherkinType)) {
|
|
103
|
+
// ② getByRole — exact role match
|
|
104
|
+
const roles = getRoleFallbacks(gherkinType);
|
|
105
|
+
if (roles.length > 0) {
|
|
106
|
+
const exactResult = await tryRole(page, roles[0], namePattern, nth);
|
|
107
|
+
if (exactResult) {
|
|
108
|
+
return {
|
|
109
|
+
...exactResult,
|
|
110
|
+
matchMethod: 'exact_role',
|
|
111
|
+
warning: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ③ getByRole — fallback roles
|
|
116
|
+
for (let i = 1; i < roles.length; i++) {
|
|
117
|
+
const fallbackResult = await tryRole(page, roles[i], namePattern, nth);
|
|
118
|
+
if (fallbackResult) {
|
|
119
|
+
return {
|
|
120
|
+
...fallbackResult,
|
|
121
|
+
matchMethod: 'role_fallback',
|
|
122
|
+
warning: `Gherkin says '${gherkinType}' but found role '${roles[i]}'`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ④ getByLabel — for form elements
|
|
129
|
+
const labelResult = await tryLabel(page, namePattern, nth);
|
|
130
|
+
if (labelResult) return labelResult;
|
|
131
|
+
|
|
132
|
+
// ⑤ getByPlaceholder — for input fields
|
|
133
|
+
const placeholderResult = await tryPlaceholder(page, namePattern, nth);
|
|
134
|
+
if (placeholderResult) return placeholderResult;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ⑥ getByText — final fallback
|
|
138
|
+
const textResult = await tryText(page, namePattern, nth);
|
|
139
|
+
if (textResult) return textResult;
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ① getByTestId
|
|
145
|
+
async function tryTestId(page: Page, ref: string, nth: number): Promise<FindResult | null> {
|
|
146
|
+
const normalized = ref.toLowerCase().replace(/[\s_-]+/g, '-');
|
|
147
|
+
const patterns = [normalized, ref.toLowerCase().replace(/\s+/g, '')];
|
|
148
|
+
|
|
149
|
+
for (const pattern of patterns) {
|
|
150
|
+
try {
|
|
151
|
+
const locator = page.locator(`[data-testid*="${pattern}" i]`);
|
|
152
|
+
const count = await locator.count();
|
|
153
|
+
if (count > 0) {
|
|
154
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
155
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
156
|
+
const testid = await target.getAttribute('data-testid');
|
|
157
|
+
return {
|
|
158
|
+
locator: target,
|
|
159
|
+
selectorType: 'testid',
|
|
160
|
+
selectorValue: testid || pattern,
|
|
161
|
+
role: '',
|
|
162
|
+
matchMethod: 'testid',
|
|
163
|
+
warning: null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Continue
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ② ③ getByRole
|
|
175
|
+
async function tryRole(
|
|
176
|
+
page: Page,
|
|
177
|
+
role: string,
|
|
178
|
+
namePattern: RegExp,
|
|
179
|
+
nth: number
|
|
180
|
+
): Promise<FindResult | null> {
|
|
181
|
+
try {
|
|
182
|
+
const locator = page.getByRole(role as any, { name: namePattern });
|
|
183
|
+
const count = await locator.count();
|
|
184
|
+
if (count > 0) {
|
|
185
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
186
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
187
|
+
// Get the actual accessible name for the selector
|
|
188
|
+
const accessibleName = await target.evaluate((el) =>
|
|
189
|
+
el.getAttribute('aria-label') || el.textContent?.trim()?.substring(0, 100) || ''
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
locator: target,
|
|
193
|
+
selectorType: 'role',
|
|
194
|
+
selectorValue: role,
|
|
195
|
+
role: role,
|
|
196
|
+
matchMethod: 'exact_role',
|
|
197
|
+
warning: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// Role not found
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ④ getByLabel
|
|
208
|
+
async function tryLabel(page: Page, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
209
|
+
try {
|
|
210
|
+
const locator = page.getByLabel(namePattern);
|
|
211
|
+
const count = await locator.count();
|
|
212
|
+
if (count > 0) {
|
|
213
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
214
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
215
|
+
return {
|
|
216
|
+
locator: target,
|
|
217
|
+
selectorType: 'label',
|
|
218
|
+
selectorValue: '', // Will be filled from attrs
|
|
219
|
+
role: '',
|
|
220
|
+
matchMethod: 'label',
|
|
221
|
+
warning: null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// Label not found
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ⑤ getByPlaceholder
|
|
232
|
+
async function tryPlaceholder(page: Page, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
233
|
+
try {
|
|
234
|
+
const locator = page.getByPlaceholder(namePattern);
|
|
235
|
+
const count = await locator.count();
|
|
236
|
+
if (count > 0) {
|
|
237
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
238
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
239
|
+
const placeholder = await target.getAttribute('placeholder');
|
|
240
|
+
return {
|
|
241
|
+
locator: target,
|
|
242
|
+
selectorType: 'placeholder',
|
|
243
|
+
selectorValue: placeholder || '',
|
|
244
|
+
role: '',
|
|
245
|
+
matchMethod: 'placeholder',
|
|
246
|
+
warning: null,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Placeholder not found
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ⑥ getByText
|
|
257
|
+
async function tryText(page: Page, namePattern: RegExp, nth: number): Promise<FindResult | null> {
|
|
258
|
+
try {
|
|
259
|
+
const locator = page.getByText(namePattern);
|
|
260
|
+
const count = await locator.count();
|
|
261
|
+
if (count > 0) {
|
|
262
|
+
const target = nth > 0 ? locator.nth(nth) : locator.first();
|
|
263
|
+
if (await target.isVisible({ timeout: 3000 })) {
|
|
264
|
+
return {
|
|
265
|
+
locator: target,
|
|
266
|
+
selectorType: 'text',
|
|
267
|
+
selectorValue: '', // Will be filled from attrs
|
|
268
|
+
role: '',
|
|
269
|
+
matchMethod: 'text_only',
|
|
270
|
+
warning: null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Text not found
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function extractElementAttributes(locator: Locator): Promise<{
|
|
281
|
+
testid: string | null;
|
|
282
|
+
tag: string;
|
|
283
|
+
accessibleName: string;
|
|
284
|
+
placeholder: string | null;
|
|
285
|
+
label: string | null;
|
|
286
|
+
}> {
|
|
287
|
+
try {
|
|
288
|
+
return await locator.evaluate((el) => {
|
|
289
|
+
// Find associated label
|
|
290
|
+
let label: string | null = null;
|
|
291
|
+
if (el.id) {
|
|
292
|
+
const labelEl = el.ownerDocument.querySelector(`label[for="${el.id}"]`);
|
|
293
|
+
if (labelEl) label = labelEl.textContent?.trim() || null;
|
|
294
|
+
}
|
|
295
|
+
if (!label && el.closest('label')) {
|
|
296
|
+
label = el.closest('label')?.textContent?.trim() || null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
testid: el.getAttribute('data-testid'),
|
|
301
|
+
tag: el.tagName.toLowerCase(),
|
|
302
|
+
accessibleName: el.getAttribute('aria-label') || el.textContent?.trim()?.substring(0, 100) || '',
|
|
303
|
+
placeholder: el.getAttribute('placeholder'),
|
|
304
|
+
label,
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
return { testid: null, tag: 'unknown', accessibleName: '', placeholder: null, label: null };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function buildUnresolvedElement(gherkinRef: string, gherkinType: string): LiveElement {
|
|
313
|
+
return {
|
|
314
|
+
gherkinRef,
|
|
315
|
+
gherkinType,
|
|
316
|
+
selectorType: '',
|
|
317
|
+
selectorValue: '',
|
|
318
|
+
role: '',
|
|
319
|
+
name: gherkinRef,
|
|
320
|
+
testid: null,
|
|
321
|
+
tag: '',
|
|
322
|
+
placeholder: null,
|
|
323
|
+
label: null,
|
|
324
|
+
context: 'page',
|
|
325
|
+
matchMethod: 'unresolved',
|
|
326
|
+
exact: false,
|
|
327
|
+
warning: 'Element not found on page',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function escapeRegex(str: string): string {
|
|
332
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
333
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { LiveScanner } from './scanner';
|
|
2
|
+
export { findElement } from './element-finder';
|
|
3
|
+
export { getRoleFallbacks, isTextOnlyType } from './role-fallback';
|
|
4
|
+
export { writeMatrix } from './matrix-writer';
|
|
5
|
+
export { readMatrix, mergeMatrixElements } from './matrix-reader';
|
|
6
|
+
export { readBaseUrlFromPlaywrightConfig } from './config-reader';
|
|
7
|
+
export * from './types';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix Reader
|
|
3
|
+
* Loads and parses a .live-scan.yaml file for consumption by the map command.
|
|
4
|
+
* Merges elements across scenarios (first match wins).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as yaml from 'yaml';
|
|
9
|
+
import { LiveElement, LiveScanResult } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read a .live-scan.yaml file and return the parsed result.
|
|
13
|
+
* Returns null if file doesn't exist.
|
|
14
|
+
*/
|
|
15
|
+
export function readMatrix(filePath: string): LiveScanResult | null {
|
|
16
|
+
if (!fs.existsSync(filePath)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
21
|
+
const parsed = yaml.parse(content);
|
|
22
|
+
|
|
23
|
+
if (!parsed || !parsed.scenarios) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return parsed as LiveScanResult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge all elements across scenarios into a flat map.
|
|
32
|
+
* First successful (non-unresolved) match wins for duplicate keys.
|
|
33
|
+
*/
|
|
34
|
+
export function mergeMatrixElements(result: LiveScanResult): Record<string, LiveElement> {
|
|
35
|
+
const merged: Record<string, LiveElement> = {};
|
|
36
|
+
|
|
37
|
+
for (const scenario of Object.values(result.scenarios)) {
|
|
38
|
+
const elements = (scenario as any).elements;
|
|
39
|
+
if (!elements) continue;
|
|
40
|
+
|
|
41
|
+
for (const [key, raw] of Object.entries(elements)) {
|
|
42
|
+
const el = raw as any;
|
|
43
|
+
|
|
44
|
+
// Convert from YAML snake_case to interface camelCase
|
|
45
|
+
const element: LiveElement = {
|
|
46
|
+
gherkinRef: el.name || key,
|
|
47
|
+
gherkinType: el.gherkin_type || '',
|
|
48
|
+
selectorType: el.selector_type || '',
|
|
49
|
+
selectorValue: el.selector_value || '',
|
|
50
|
+
role: el.role || '',
|
|
51
|
+
name: el.name || '',
|
|
52
|
+
testid: el.testid || null,
|
|
53
|
+
tag: el.tag || '',
|
|
54
|
+
placeholder: el.placeholder || null,
|
|
55
|
+
label: el.label || null,
|
|
56
|
+
context: (el.context as any) || 'page',
|
|
57
|
+
matchMethod: (el.match_method as any) || 'unresolved',
|
|
58
|
+
exact: el.exact === true,
|
|
59
|
+
warning: el.warning || null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// First non-unresolved match wins
|
|
63
|
+
if (!merged[key] || (merged[key].matchMethod === 'unresolved' && element.matchMethod !== 'unresolved')) {
|
|
64
|
+
merged[key] = element;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix Writer
|
|
3
|
+
* Writes LiveScanResult to a .live-scan.yaml file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as yaml from 'yaml';
|
|
8
|
+
import { LiveScanResult } from './types';
|
|
9
|
+
|
|
10
|
+
export function writeMatrix(result: LiveScanResult, outputPath: string): void {
|
|
11
|
+
const output: any = {
|
|
12
|
+
screen: result.screen,
|
|
13
|
+
baseUrl: result.baseUrl,
|
|
14
|
+
scannedAt: result.scannedAt,
|
|
15
|
+
scenarios: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
for (const [scenarioKey, scenario] of Object.entries(result.scenarios)) {
|
|
19
|
+
const scenarioData: any = {
|
|
20
|
+
auth: scenario.auth,
|
|
21
|
+
path: scenario.path,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (scenario.extends) {
|
|
25
|
+
scenarioData.extends = scenario.extends;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
scenarioData.elements = {};
|
|
29
|
+
|
|
30
|
+
for (const [elementKey, element] of Object.entries(scenario.elements)) {
|
|
31
|
+
scenarioData.elements[elementKey] = {
|
|
32
|
+
gherkin_type: element.gherkinType,
|
|
33
|
+
selector_type: element.selectorType,
|
|
34
|
+
selector_value: element.selectorValue,
|
|
35
|
+
role: element.role,
|
|
36
|
+
name: element.name,
|
|
37
|
+
testid: element.testid,
|
|
38
|
+
tag: element.tag,
|
|
39
|
+
placeholder: element.placeholder,
|
|
40
|
+
label: element.label,
|
|
41
|
+
context: element.context,
|
|
42
|
+
match_method: element.matchMethod,
|
|
43
|
+
exact: element.exact,
|
|
44
|
+
warning: element.warning,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
output.scenarios[scenarioKey] = scenarioData;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const header = [
|
|
52
|
+
`# Auto-generated by sungen live-scan`,
|
|
53
|
+
`# Screen: ${result.screen} | Base URL: ${result.baseUrl}`,
|
|
54
|
+
`# Scanned: ${result.scannedAt}`,
|
|
55
|
+
`# Do not edit manually — re-run 'sungen live-scan' to regenerate`,
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
const yamlStr = yaml.stringify(output, {
|
|
60
|
+
lineWidth: 120,
|
|
61
|
+
defaultStringType: 'QUOTE_DOUBLE',
|
|
62
|
+
nullStr: 'null',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
fs.writeFileSync(outputPath, header + yamlStr, 'utf-8');
|
|
66
|
+
}
|