@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.
Files changed (103) hide show
  1. package/README.md +2 -1
  2. package/dist/cli/commands/live-scan-command.d.ts +9 -0
  3. package/dist/cli/commands/live-scan-command.d.ts.map +1 -0
  4. package/dist/cli/commands/live-scan-command.js +72 -0
  5. package/dist/cli/commands/live-scan-command.js.map +1 -0
  6. package/dist/cli/index.js +29 -0
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/live-scanner/config-reader.d.ts +10 -0
  9. package/dist/core/live-scanner/config-reader.d.ts.map +1 -0
  10. package/dist/core/live-scanner/config-reader.js +87 -0
  11. package/dist/core/live-scanner/config-reader.js.map +1 -0
  12. package/dist/core/live-scanner/element-finder.d.ts +20 -0
  13. package/dist/core/live-scanner/element-finder.d.ts.map +1 -0
  14. package/dist/core/live-scanner/element-finder.js +289 -0
  15. package/dist/core/live-scanner/element-finder.js.map +1 -0
  16. package/dist/core/live-scanner/index.d.ts +8 -0
  17. package/dist/core/live-scanner/index.d.ts.map +1 -0
  18. package/dist/core/live-scanner/index.js +33 -0
  19. package/dist/core/live-scanner/index.js.map +1 -0
  20. package/dist/core/live-scanner/matrix-reader.d.ts +17 -0
  21. package/dist/core/live-scanner/matrix-reader.d.ts.map +1 -0
  22. package/dist/core/live-scanner/matrix-reader.js +97 -0
  23. package/dist/core/live-scanner/matrix-reader.js.map +1 -0
  24. package/dist/core/live-scanner/matrix-writer.d.ts +7 -0
  25. package/dist/core/live-scanner/matrix-writer.d.ts.map +1 -0
  26. package/dist/core/live-scanner/matrix-writer.js +92 -0
  27. package/dist/core/live-scanner/matrix-writer.js.map +1 -0
  28. package/dist/core/live-scanner/role-fallback.d.ts +15 -0
  29. package/dist/core/live-scanner/role-fallback.d.ts.map +1 -0
  30. package/dist/core/live-scanner/role-fallback.js +45 -0
  31. package/dist/core/live-scanner/role-fallback.js.map +1 -0
  32. package/dist/core/live-scanner/scanner.d.ts +13 -0
  33. package/dist/core/live-scanner/scanner.d.ts.map +1 -0
  34. package/dist/core/live-scanner/scanner.js +225 -0
  35. package/dist/core/live-scanner/scanner.js.map +1 -0
  36. package/dist/core/live-scanner/step-replayer.d.ts +26 -0
  37. package/dist/core/live-scanner/step-replayer.d.ts.map +1 -0
  38. package/dist/core/live-scanner/step-replayer.js +184 -0
  39. package/dist/core/live-scanner/step-replayer.js.map +1 -0
  40. package/dist/core/live-scanner/types.d.ts +50 -0
  41. package/dist/core/live-scanner/types.d.ts.map +1 -0
  42. package/dist/core/live-scanner/types.js +14 -0
  43. package/dist/core/live-scanner/types.js.map +1 -0
  44. package/dist/generators/cli.js +1 -1
  45. package/dist/generators/scaffold-generator/index.d.ts +12 -1
  46. package/dist/generators/scaffold-generator/index.d.ts.map +1 -1
  47. package/dist/generators/scaffold-generator/index.js +118 -2
  48. package/dist/generators/scaffold-generator/index.js.map +1 -1
  49. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
  50. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
  51. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
  52. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
  53. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
  54. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
  55. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
  56. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
  57. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
  58. package/dist/generators/test-generator/patterns/assertion-patterns.d.ts.map +1 -1
  59. package/dist/generators/test-generator/patterns/assertion-patterns.js +6 -3
  60. package/dist/generators/test-generator/patterns/assertion-patterns.js.map +1 -1
  61. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  62. package/dist/generators/test-generator/template-engine.js +3 -5
  63. package/dist/generators/test-generator/template-engine.js.map +1 -1
  64. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  65. package/dist/generators/test-generator/utils/data-resolver.js +18 -7
  66. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  67. package/dist/generators/test-generator/utils/selector-resolver.d.ts +1 -0
  68. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  69. package/dist/generators/test-generator/utils/selector-resolver.js +6 -0
  70. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  71. package/dist/input/cli-adapter.d.ts +4 -0
  72. package/dist/input/cli-adapter.d.ts.map +1 -1
  73. package/dist/input/cli-adapter.js +16 -1
  74. package/dist/input/cli-adapter.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/commands/live-scan-command.ts +79 -0
  77. package/src/cli/index.ts +35 -2
  78. package/src/config/default.config.yaml +6 -0
  79. package/src/core/live-scanner/config-reader.ts +57 -0
  80. package/src/core/live-scanner/element-finder.ts +333 -0
  81. package/src/core/live-scanner/index.ts +7 -0
  82. package/src/core/live-scanner/matrix-reader.ts +70 -0
  83. package/src/core/live-scanner/matrix-writer.ts +66 -0
  84. package/src/core/live-scanner/role-fallback.ts +43 -0
  85. package/src/core/live-scanner/scanner.ts +231 -0
  86. package/src/core/live-scanner/step-replayer.ts +219 -0
  87. package/src/core/live-scanner/types.ts +56 -0
  88. package/src/generators/cli.ts +1 -1
  89. package/src/generators/scaffold-generator/index.ts +127 -4
  90. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/radio-select-action.hbs +1 -1
  91. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +2 -2
  92. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +2 -2
  93. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +6 -2
  94. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-variable-assertion.hbs +1 -1
  95. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/label.hbs +1 -1
  96. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/placeholder.hbs +1 -1
  97. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/role.hbs +1 -1
  98. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/text.hbs +1 -1
  99. package/src/generators/test-generator/patterns/assertion-patterns.ts +6 -3
  100. package/src/generators/test-generator/template-engine.ts +3 -4
  101. package/src/generators/test-generator/utils/data-resolver.ts +20 -9
  102. package/src/generators/test-generator/utils/selector-resolver.ts +8 -0
  103. 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
+ }