@sun-asterisk/sungen 2.0.0 → 2.0.2

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 (39) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +2 -2
  3. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-cell-by-filter.hbs +2 -2
  4. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-cell-by-index.hbs +2 -2
  5. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  6. package/dist/generators/test-generator/code-generator.js +10 -0
  7. package/dist/generators/test-generator/code-generator.js.map +1 -1
  8. package/dist/generators/test-generator/step-mapper.d.ts +4 -0
  9. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  10. package/dist/generators/test-generator/step-mapper.js +8 -0
  11. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  12. package/dist/orchestrator/project-initializer.d.ts +4 -0
  13. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  14. package/dist/orchestrator/project-initializer.js +11 -410
  15. package/dist/orchestrator/project-initializer.js.map +1 -1
  16. package/dist/orchestrator/templates/ai-rules.md +189 -0
  17. package/dist/orchestrator/templates/gitignore +16 -0
  18. package/dist/orchestrator/templates/playwright.config.d.ts +10 -0
  19. package/dist/orchestrator/templates/playwright.config.d.ts.map +1 -0
  20. package/dist/orchestrator/templates/playwright.config.js +77 -0
  21. package/dist/orchestrator/templates/playwright.config.js.map +1 -0
  22. package/dist/orchestrator/templates/playwright.config.ts +80 -0
  23. package/dist/orchestrator/templates/readme.md +197 -0
  24. package/docs/gherkin standards/gherkin-core-standard.md +377 -0
  25. package/docs/gherkin standards/gherkin-core-standard.vi.md +303 -0
  26. package/docs/gherkin-dictionary.md +1071 -0
  27. package/docs/makeauth.md +225 -0
  28. package/package.json +3 -2
  29. package/src/cli/index.ts +1 -1
  30. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +2 -2
  31. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-cell-by-filter.hbs +2 -2
  32. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-cell-by-index.hbs +2 -2
  33. package/src/generators/test-generator/code-generator.ts +11 -0
  34. package/src/generators/test-generator/step-mapper.ts +9 -0
  35. package/src/orchestrator/project-initializer.ts +12 -410
  36. package/src/orchestrator/templates/ai-rules.md +189 -0
  37. package/src/orchestrator/templates/gitignore +16 -0
  38. package/src/orchestrator/templates/playwright.config.ts +80 -0
  39. package/src/orchestrator/templates/readme.md +197 -0
@@ -0,0 +1,1071 @@
1
+ # Sungen Gherkin Dictionary v2
2
+
3
+ Complete mapping from Gherkin steps to Playwright code.
4
+ This is the compiler rulebook — deterministic, no AI needed at compile time.
5
+
6
+ ---
7
+
8
+ ## Project Structure
9
+
10
+ ### Screen directory (multi-file)
11
+
12
+ ```
13
+ qa/screens/<screen-name>/
14
+ ├── features/
15
+ │ ├── <screen>.feature # main scenarios (page load, content)
16
+ │ ├── <screen>-navigation.feature # navigation scenarios
17
+ │ ├── <screen>-<feature>.feature # additional feature-specific scenarios
18
+ │ └── ...
19
+ ├── selectors/
20
+ │ ├── <screen>.yaml # shared selectors (header, nav, page entry)
21
+ │ ├── <screen>-<feature>.yaml # feature-specific selectors
22
+ │ ├── <screen>.override.yaml # manual overrides (never auto-modified)
23
+ │ └── ...
24
+ └── test-data/
25
+ ├── <screen>.yaml # shared test data
26
+ ├── <screen>-<feature>.yaml # feature-specific test data
27
+ ├── <screen>.override.yaml # manual overrides (never auto-modified)
28
+ └── ...
29
+ ```
30
+
31
+ ### Merge rules at compile time
32
+
33
+ 1. **Selectors:** All `.yaml` in `selectors/` are merged into one map. `.override.yaml` takes precedence.
34
+ 2. **Test-data:** All `.yaml` in `test-data/` are merged. `.override.yaml` takes precedence.
35
+ 3. **Features:** Each `.feature` compiles to its own `.spec.ts`:
36
+
37
+ ```
38
+ features/awards.feature → specs/generated/awards/awards.spec.ts
39
+ features/awards-navigation.feature → specs/generated/awards/awards-navigation.spec.ts
40
+ features/awards-table.feature → specs/generated/awards/awards-table.spec.ts
41
+ ```
42
+
43
+ ### CLI commands
44
+
45
+ ```bash
46
+ sungen init # scaffold project + AI rules
47
+ sungen add --screen awards --path /awards # create screen with main feature
48
+ sungen add --screen awards --feature navigation # add feature file to existing screen
49
+ sungen makeauth <role> # capture browser auth state
50
+ sungen generate --screen awards # compile all features → .spec.ts
51
+ sungen generate --all # compile all screens
52
+ ```
53
+
54
+ ### Feature file template
55
+
56
+ Every `.feature` must include `Path:` so AI knows which URL to visit:
57
+
58
+ ```gherkin
59
+ Feature: awards Screen
60
+
61
+ As a user
62
+ I want to interact with the awards screen
63
+ So that I can accomplish my tasks
64
+ Path: /awards
65
+ ```
66
+
67
+ ### AI rules files
68
+
69
+ Generated by `sungen init`:
70
+
71
+ ```
72
+ .github/copilot-instructions.md # GitHub Copilot
73
+ CLAUDE.md # Claude Code
74
+ ```
75
+
76
+ Both contain this dictionary, YAML schema, and instructions for AI to generate/fix files.
77
+
78
+ ---
79
+
80
+ ## Selector YAML Schema v2
81
+
82
+ Every `[element]` in Gherkin resolves to a selector entry:
83
+
84
+ ```yaml
85
+ element.key:
86
+ # === Core ===
87
+ type: 'testid'|'role'|'text'|'label'|'placeholder'|'locator'|'page'|'upload'|'frame'
88
+ value: 'button' # role name, testid value, CSS selector, etc.
89
+ name: 'Submit' # accessible name (for role-based)
90
+ nth: 0 # element index when multiple matches
91
+
92
+ # === Disambiguation ===
93
+ exact: true|false # exact name/text match (default: false)
94
+ scope: 'desktop navigation' # parent landmark aria-label to scope within
95
+ match: 'exact'|'partial' # for getByText matching (default: partial)
96
+
97
+ # === Complex Elements ===
98
+ variant: 'native'|'custom'|'dragdrop' # dropdown / upload type
99
+ trigger: 'Upload Photo' # visible button that triggers hidden input
100
+ frame: '#payment-iframe' # iframe selector for cross-frame elements
101
+ contenteditable: true # rich text editor (contenteditable div)
102
+
103
+ # === Table Definition ===
104
+ columns: # only for type: 'table'
105
+ name:
106
+ index: 0
107
+ header: 'Name'
108
+ status:
109
+ index: 2
110
+ header: 'Status'
111
+ elements: # interactive elements inside column cells
112
+ edit:
113
+ type: 'role'
114
+ value: 'button'
115
+ name: 'Edit'
116
+ pagination: # table pagination selectors
117
+ next: '[data-testid="next-page"]'
118
+ prev: '[data-testid="prev-page"]'
119
+ pageSize: '[data-testid="page-size"]'
120
+
121
+ # === Assertion Helpers ===
122
+ attribute: 'src' # attribute to check (for "has" assertions)
123
+ pattern: '/.*\\.png$/' # regex for attribute value
124
+ ```
125
+
126
+ ### Locator Resolution Priority
127
+
128
+ ```
129
+ 1. data-testid → page.getByTestId(value)
130
+ 2. role + name → page.getByRole(value, { name, exact })
131
+ 3. label → page.getByLabel(value, { exact })
132
+ 4. placeholder → page.getByPlaceholder(value, { exact })
133
+ 5. text → page.getByText(value, { exact: match=='exact' })
134
+ 6. locator → page.locator(value)
135
+ 7. page → page.goto(value)
136
+ 8. upload → page.locator(value).setInputFiles()
137
+ 9. frame → page.frameLocator(value)
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Formal Grammar
143
+
144
+ ```
145
+ STEP := ACTOR VERB_PHRASE
146
+ ACTOR := "User"
147
+
148
+ VERB_PHRASE := ACTION_VP
149
+ | WAIT_VP
150
+ | NAV_VP
151
+ | SCROLL_VP
152
+ | SWITCH_VP
153
+ | TABLE_VP
154
+
155
+ # ─── Basic actions ────────────────────────────────────────────
156
+ ACTION_VP := ACTION TARGET_EXPR (DATA_EXPR)? (STATE_EXPR)? # ①②③④
157
+ | ACTION TARGET_EXPR "to" TARGET_EXPR # ⑤ drag
158
+ | ACTION KEY "key" ("on" TARGET_EXPR)? # ⑥⑦ keyboard
159
+ | ACTION TARGET_EXPR ("has"|"contains") DATA_EXPR # ⑫⑰ has/contains
160
+ | ACTION TARGET_EXPR "has" TARGET_EXPR # ⑫ has column
161
+ | "double click" TARGET_EXPR # double click
162
+
163
+ # ─── Wait ─────────────────────────────────────────────────────
164
+ WAIT_VP := "wait for" TIMEOUT # ⑧
165
+ | "wait for" TARGET_EXPR (DATA_EXPR)? (STATE_EXPR)? # ⑨
166
+
167
+ # ─── Navigation ───────────────────────────────────────────────
168
+ NAV_VP := "is on" TARGET_EXPR "page" # ⑯
169
+ | "navigate to" TARGET_EXPR "page" (DATA_EXPR)? # ⑯
170
+
171
+ # ─── Scroll ───────────────────────────────────────────────────
172
+ SCROLL_VP := "scroll to" TARGET_EXPR # ⑩
173
+
174
+ # ─── Frame switch ─────────────────────────────────────────────
175
+ SWITCH_VP := "switch to" TARGET_EXPR "frame" # ⑪
176
+
177
+ # ─── Table ────────────────────────────────────────────────────
178
+ TABLE_VP := "see" TARGET_EXPR "table" TABLE_EXPR # ⑬⑭
179
+ | ACTION TARGET_EXPR "in" TARGET_EXPR "table"
180
+ TABLE_ROW_EXPR # ⑮
181
+
182
+ TABLE_EXPR := TABLE_ROW_EXPR
183
+ | "has" DATA_EXPR "rows" # row count
184
+ | "has" TARGET_EXPR "column" # column exists
185
+ | "is empty" # empty state
186
+ | "empty message" DATA_EXPR # empty message
187
+
188
+ TABLE_ROW_EXPR := "row" (NUMBER)? (DATA_EXPR)? (TABLE_COL_EXPR)?
189
+ | "row" (DATA_EXPR)? ELEMENT_TYPE # row checkbox
190
+
191
+ TABLE_COL_EXPR := ("has")? TARGET_EXPR ("cell")? (DATA_EXPR)?
192
+
193
+ # ─── Terminals ────────────────────────────────────────────────
194
+ TARGET_EXPR := "[" IDENTIFIER "]" (ELEMENT_TYPE)?
195
+ ELEMENT_TYPE := "button"|"link"|"field"|"heading"|"text"|"image"|"checkbox"
196
+ | "radio"|"switch"|"dropdown"|"option"|"dialog"|"modal"|"menu"
197
+ | "menuitem"|"tab"|"tabpanel"|"list"|"listitem"|"table"|"row"
198
+ | "cell"|"column"|"columnheader"|"region"|"section"|"nav"
199
+ | "banner"|"header"|"footer"|"alert"|"spinner"|"progressbar"
200
+ | "slider"|"tree"|"treeitem"|"tooltip"|"icon"|"uploader"
201
+ | "file"|"frame"|"iframe"|"textarea"|"page"
202
+ DATA_EXPR := "with" "{{" IDENTIFIER "}}"
203
+ STATE_EXPR := "is" STATE
204
+ STATE := "hidden"|"visible"|"disabled"|"enabled"|"checked"
205
+ | "unchecked"|"focused"|"empty"|"loading"|"selected"
206
+ | "sorted ascending"|"sorted descending"
207
+ ACTION := "click"|"fill"|"select"|"check"|"uncheck"|"toggle"
208
+ | "upload"|"hover"|"drag"|"clear"|"see"|"press"|"expand"
209
+ | "collapse"|"double click"
210
+ KEY := "Enter"|"Escape"|"Tab"|"Backspace"|"Delete"|"Space"
211
+ | "ArrowUp"|"ArrowDown"|"ArrowLeft"|"ArrowRight"
212
+ | "Home"|"End"|"PageUp"|"PageDown"
213
+ TIMEOUT := NUMBER ("seconds"|"ms")
214
+ NUMBER := [0-9]+
215
+ IDENTIFIER := [a-zA-Z0-9_ ./*:-]+
216
+ ```
217
+
218
+ ---
219
+
220
+ ## 17 Pattern Shapes
221
+
222
+ ### ① Simple action: `action [Target] type`
223
+
224
+ ```gherkin
225
+ When User click [Submit] button
226
+ When User check [Remember me] checkbox
227
+ When User uncheck [Newsletter] checkbox
228
+ When User toggle [Dark mode] switch
229
+ When User clear [Search] field
230
+ When User hover [Info] icon
231
+ When User expand [Row 1] row
232
+ When User collapse [Row 1] row
233
+ ```
234
+
235
+ **Compiler rule:**
236
+ ```
237
+ locator = resolve([Target], type)
238
+ code = locator.{action}()
239
+ ```
240
+
241
+ **Playwright output:**
242
+ ```typescript
243
+ // click button
244
+ await page.getByRole('button', { name: 'Submit', exact: true }).click();
245
+
246
+ // check checkbox
247
+ await page.getByRole('checkbox', { name: 'Remember me' }).check();
248
+
249
+ // uncheck checkbox
250
+ await page.getByRole('checkbox', { name: 'Newsletter' }).uncheck();
251
+
252
+ // toggle switch
253
+ await page.getByRole('switch', { name: 'Dark mode' }).click();
254
+
255
+ // clear field
256
+ await page.getByRole('textbox', { name: 'Search' }).clear();
257
+
258
+ // hover
259
+ await page.getByRole('img', { name: 'Info' }).hover();
260
+ ```
261
+
262
+ ---
263
+
264
+ ### ② Action with data: `action [Target] type with {{Value}}`
265
+
266
+ ```gherkin
267
+ When User fill [Email] field with {{valid_email}}
268
+ When User fill [Message] textarea with {{message_body}}
269
+ When User select [Country] dropdown with {{country_name}}
270
+ When User upload [Avatar] file with {{avatar_path}}
271
+ When User click [Teammate] button with {{teammate_name}}
272
+ Then User see [Title] heading with {{page_title}}
273
+ Then User see [Price] text with {{item_price}}
274
+ ```
275
+
276
+ **Compiler rule:**
277
+ ```
278
+ locator = resolve([Target], type)
279
+ value = resolveData({{Value}})
280
+ if action == 'fill': code = locator.fill(value)
281
+ if action == 'select': code = locator.selectOption(value) or click+click
282
+ if action == 'upload': code = locator.setInputFiles(value)
283
+ if action == 'click': code = page.getByText(value).click() or locator.filter({hasText}).click()
284
+ if action == 'see': code = expect(locator).toBeVisible() with text check
285
+ ```
286
+
287
+ **Playwright output:**
288
+ ```typescript
289
+ // fill text field
290
+ await page.getByRole('textbox', { name: 'Email', exact: true }).fill('test@example.com');
291
+
292
+ // fill contenteditable
293
+ await page.locator('[contenteditable="true"]').click();
294
+ await page.locator('[contenteditable="true"]').pressSequentially('Hello world!');
295
+
296
+ // select native dropdown
297
+ await page.getByRole('combobox', { name: 'Country' }).selectOption('Vietnam');
298
+
299
+ // select custom dropdown
300
+ await page.getByRole('combobox', { name: 'Country' }).click();
301
+ await page.getByRole('option', { name: 'Vietnam' }).click();
302
+
303
+ // upload file
304
+ await page.getByLabel('Avatar').setInputFiles('specs/storage/avatar.png');
305
+
306
+ // click with text filter
307
+ await page.getByText('Nguyễn Thanh Tùng').click();
308
+
309
+ // see heading with value
310
+ await expect(page.getByRole('heading', { name: 'Hệ thống giải thưởng SAA 2025' })).toBeVisible();
311
+
312
+ // see text with exact match
313
+ await expect(page.getByText('5.000.000 VNĐ', { exact: true })).toBeVisible();
314
+ ```
315
+
316
+ ---
317
+
318
+ ### ③ State assertion: `action [Target] type is state`
319
+
320
+ ```gherkin
321
+ Then User see [Submit] button is disabled
322
+ Then User see [Submit] button is enabled
323
+ Then User see [Modal] dialog is hidden
324
+ Then User see [Welcome] heading is visible
325
+ Then User see [Remember me] checkbox is checked
326
+ Then User see [Newsletter] checkbox is unchecked
327
+ Then User see [Email] field is focused
328
+ Then User see [Search] field is empty
329
+ ```
330
+
331
+ **Compiler rule:**
332
+ ```
333
+ locator = resolve([Target], type)
334
+ switch(state):
335
+ hidden → expect(locator).toBeHidden()
336
+ visible → expect(locator).toBeVisible()
337
+ disabled → expect(locator).toBeDisabled()
338
+ enabled → expect(locator).toBeEnabled()
339
+ checked → expect(locator).toBeChecked()
340
+ unchecked → expect(locator).not.toBeChecked()
341
+ focused → expect(locator).toBeFocused()
342
+ empty → expect(locator).toHaveText('')
343
+ ```
344
+
345
+ **Playwright output:**
346
+ ```typescript
347
+ await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
348
+ await expect(page.getByRole('dialog')).toBeHidden();
349
+ await expect(page.getByRole('checkbox', { name: 'Remember me' })).toBeChecked();
350
+ await expect(page.getByRole('textbox', { name: 'Email' })).toBeFocused();
351
+ await expect(page.getByRole('textbox', { name: 'Search' })).toHaveText('');
352
+ ```
353
+
354
+ ---
355
+
356
+ ### ④ State with data: `action [Target] type with {{Value}} is state`
357
+
358
+ ```gherkin
359
+ Then User see [Panel] dialog with {{dialog_title}} is hidden
360
+ Then User see [Email] field with {{error_message}} is disabled
361
+ ```
362
+
363
+ **Compiler rule:**
364
+ ```
365
+ locator = resolve([Target], type).filter({ hasText: value })
366
+ apply state assertion on locator
367
+ ```
368
+
369
+ **Playwright output:**
370
+ ```typescript
371
+ await expect(
372
+ page.getByRole('dialog').filter({ hasText: 'Confirm Delete' })
373
+ ).toBeHidden();
374
+ ```
375
+
376
+ ---
377
+
378
+ ### ⑤ Two targets: `action [Source] to [Destination]`
379
+
380
+ ```gherkin
381
+ When User drag [Card A] to [Column B]
382
+ ```
383
+
384
+ **Compiler rule:**
385
+ ```
386
+ source = resolve([Source])
387
+ dest = resolve([Destination])
388
+ code = source.dragTo(dest)
389
+ ```
390
+
391
+ **Playwright output:**
392
+ ```typescript
393
+ await page.getByTestId('card-a').dragTo(page.getByTestId('column-b'));
394
+ ```
395
+
396
+ ---
397
+
398
+ ### ⑥ Global key: `action Key key`
399
+
400
+ ```gherkin
401
+ When User press Escape key
402
+ When User press Tab key
403
+ When User press Space key
404
+ ```
405
+
406
+ **Compiler rule:**
407
+ ```
408
+ code = page.keyboard.press(Key)
409
+ ```
410
+
411
+ **Playwright output:**
412
+ ```typescript
413
+ await page.keyboard.press('Escape');
414
+ ```
415
+
416
+ ---
417
+
418
+ ### ⑦ Key on target: `action Key on [Target] type`
419
+
420
+ ```gherkin
421
+ When User press Enter on [Search] field
422
+ When User press Escape on [Modal] dialog
423
+ ```
424
+
425
+ **Compiler rule:**
426
+ ```
427
+ locator = resolve([Target], type)
428
+ code = locator.press(Key)
429
+ ```
430
+
431
+ **Playwright output:**
432
+ ```typescript
433
+ await page.getByRole('textbox', { name: 'Search' }).press('Enter');
434
+ ```
435
+
436
+ ---
437
+
438
+ ### ⑧ Wait timeout: `wait for N seconds`
439
+
440
+ ```gherkin
441
+ When User wait for 3 seconds
442
+ When User wait for 500 ms
443
+ ```
444
+
445
+ **Compiler rule:**
446
+ ```
447
+ code = page.waitForTimeout(N * multiplier)
448
+ ```
449
+
450
+ **Playwright output:**
451
+ ```typescript
452
+ await page.waitForTimeout(3000);
453
+ ```
454
+
455
+ ---
456
+
457
+ ### ⑨ Wait for element: `wait for [Target] type (with {{Value}}) (is state)`
458
+
459
+ ```gherkin
460
+ When User wait for [Modal] dialog
461
+ When User wait for [Loading] spinner is hidden
462
+ When User wait for [Dialog] dialog with {{dialog_title}}
463
+ When User wait for [Dialog] dialog with {{dialog_title}} is hidden
464
+ When User wait for [dashboard] page
465
+ ```
466
+
467
+ **Compiler rule:**
468
+ ```
469
+ locator = resolve([Target], type)
470
+ if DATA_EXPR: locator = locator.filter({ hasText: value })
471
+ state = STATE_EXPR ? mapState(state) : 'visible'
472
+ if type == 'page': code = page.waitForURL(value)
473
+ else: code = locator.waitFor({ state })
474
+ ```
475
+
476
+ **Playwright output:**
477
+ ```typescript
478
+ // wait for dialog visible
479
+ await page.getByRole('dialog').waitFor({ state: 'visible' });
480
+
481
+ // wait for spinner hidden
482
+ await page.getByRole('progressbar').waitFor({ state: 'hidden' });
483
+
484
+ // wait for dialog with title
485
+ await page.getByRole('dialog').filter({ hasText: 'Confirm' }).waitFor({ state: 'visible' });
486
+
487
+ // wait for dialog with title hidden
488
+ await page.getByRole('dialog').filter({ hasText: 'Confirm' }).waitFor({ state: 'hidden' });
489
+
490
+ // wait for page
491
+ await page.waitForURL(/\/dashboard/);
492
+ ```
493
+
494
+ ---
495
+
496
+ ### ⑩ Scroll: `scroll to [Target] type`
497
+
498
+ ```gherkin
499
+ When User scroll to [Footer] section
500
+ When User scroll to [Comments] region
501
+ ```
502
+
503
+ **Compiler rule:**
504
+ ```
505
+ locator = resolve([Target], type)
506
+ code = locator.scrollIntoViewIfNeeded()
507
+ ```
508
+
509
+ **Playwright output:**
510
+ ```typescript
511
+ await page.getByRole('contentinfo').scrollIntoViewIfNeeded();
512
+ ```
513
+
514
+ ---
515
+
516
+ ### ⑪ Frame switch: `switch to [Target] frame`
517
+
518
+ ```gherkin
519
+ When User switch to [Payment] frame
520
+ ```
521
+
522
+ After this step, all subsequent locators are scoped to the frame until the next `switch to [main] frame` or end of scenario.
523
+
524
+ **Selector YAML:**
525
+ ```yaml
526
+ payment:
527
+ type: 'frame'
528
+ value: '#payment-iframe'
529
+ ```
530
+
531
+ **Compiler rule:**
532
+ ```
533
+ frameContext = page.frameLocator(value)
534
+ // All subsequent locators use frameContext instead of page
535
+ ```
536
+
537
+ **Playwright output:**
538
+ ```typescript
539
+ // Subsequent steps use frame scope:
540
+ const frame = page.frameLocator('#payment-iframe');
541
+ await frame.getByRole('textbox', { name: 'Card number' }).fill('4242...');
542
+ ```
543
+
544
+ ---
545
+
546
+ ### ⑫ Has (count/attribute): `action [Target] type has {{Value}}`
547
+
548
+ ```gherkin
549
+ Then User see [Avatar] image has {{avatar_url}}
550
+ Then User see [Link] link has {{href_value}}
551
+ ```
552
+
553
+ **Selector YAML:**
554
+ ```yaml
555
+ avatar:
556
+ type: 'role'
557
+ value: 'img'
558
+ name: 'Avatar'
559
+ attribute: 'src'
560
+ ```
561
+
562
+ **Compiler rule:**
563
+ ```
564
+ locator = resolve([Target], type)
565
+ if selector.attribute:
566
+ code = expect(locator).toHaveAttribute(attribute, value)
567
+ ```
568
+
569
+ **Playwright output:**
570
+ ```typescript
571
+ // image has src
572
+ await expect(page.getByRole('img', { name: 'Avatar' })).toHaveAttribute('src', /avatar\.png/);
573
+
574
+ // link has href
575
+ await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
576
+ ```
577
+
578
+ ---
579
+
580
+ ### ⑬ Table cell by row index: `see [Table] table row N [Col] cell with {{Value}}`
581
+
582
+ ```gherkin
583
+ Then User see [Users] table row 1 [Name] cell with {{first_name}}
584
+ Then User see [Users] table row 1 [Email] cell with {{first_email}}
585
+ Then User see [Users] table row 3 [Status] cell with {{third_status}}
586
+ ```
587
+
588
+ **Selector YAML:**
589
+ ```yaml
590
+ users:
591
+ type: 'table'
592
+ value: 'Users'
593
+ columns:
594
+ name:
595
+ index: 0
596
+ header: 'Name'
597
+ email:
598
+ index: 1
599
+ header: 'Email'
600
+ status:
601
+ index: 2
602
+ header: 'Status'
603
+ ```
604
+
605
+ **Compiler rule:**
606
+ ```
607
+ table = page.getByRole('table', { name: selector.value })
608
+ row = table.getByRole('row').nth(N) // N+1 to skip header
609
+ cell = row.getByRole('cell').nth(columns[Col].index)
610
+ code = expect(cell).toHaveText(value)
611
+ ```
612
+
613
+ **Playwright output:**
614
+ ```typescript
615
+ const table = page.getByRole('table', { name: 'Users' });
616
+ const row = table.getByRole('row').nth(1); // skip header
617
+ const cell = row.getByRole('cell').nth(0); // Name column
618
+ await expect(cell).toHaveText('John Doe');
619
+ ```
620
+
621
+ ---
622
+
623
+ ### ⑭ Table cell by row filter: `see [Table] table row with {{Filter}} has [Col] with {{Value}}`
624
+
625
+ ```gherkin
626
+ Then User see [Users] table row with {{user_name}} has [Status] with {{expected_status}}
627
+ Then User see [Users] table row with {{user_name}} has [Email] with {{expected_email}}
628
+ ```
629
+
630
+ **Compiler rule:**
631
+ ```
632
+ table = page.getByRole('table', { name: selector.value })
633
+ row = table.getByRole('row').filter({ hasText: filterValue })
634
+ cell = row.getByRole('cell').nth(columns[Col].index)
635
+ code = expect(cell).toHaveText(value)
636
+ ```
637
+
638
+ **Playwright output:**
639
+ ```typescript
640
+ const table = page.getByRole('table', { name: 'Users' });
641
+ const row = table.getByRole('row').filter({ hasText: 'John Doe' });
642
+ const cell = row.getByRole('cell').nth(2); // Status column
643
+ await expect(cell).toHaveText('Active');
644
+ ```
645
+
646
+ ---
647
+
648
+ ### ⑮ Action in table row: `action [Element] in [Table] table row with {{Filter}}`
649
+
650
+ ```gherkin
651
+ When User click [Edit] in [Users] table row with {{user_name}}
652
+ When User click [Delete] in [Users] table row with {{user_name}}
653
+ When User check [Users] table row with {{user_name}} checkbox
654
+ When User check [Users] table select all checkbox
655
+ ```
656
+
657
+ **Compiler rule:**
658
+ ```
659
+ table = page.getByRole('table', { name: tableSelector.value })
660
+ row = table.getByRole('row').filter({ hasText: filterValue })
661
+ element = row.resolve([Element])
662
+ code = element.{action}()
663
+ ```
664
+
665
+ **Playwright output:**
666
+ ```typescript
667
+ // click button in row
668
+ const table = page.getByRole('table', { name: 'Users' });
669
+ const row = table.getByRole('row').filter({ hasText: 'John Doe' });
670
+ await row.getByRole('button', { name: 'Edit' }).click();
671
+
672
+ // check row checkbox
673
+ const row = table.getByRole('row').filter({ hasText: 'John Doe' });
674
+ await row.getByRole('checkbox').check();
675
+
676
+ // select all
677
+ await table.getByRole('checkbox', { name: 'Select all' }).check();
678
+ ```
679
+
680
+ ---
681
+
682
+ ### ⑯ Navigation: `is on [Target] page` / `navigate to [Target] page`
683
+
684
+ ```gherkin
685
+ Given User is on [login] page
686
+ Given User is on [awards] page
687
+ When User navigate to [profile] page with {{user_id}}
688
+ Then User see [dashboard] page
689
+ ```
690
+
691
+ **Selector YAML:**
692
+ ```yaml
693
+ login:
694
+ type: 'page'
695
+ value: '/login'
696
+
697
+ profile:
698
+ type: 'page'
699
+ value: '/users/{{user_id}}/profile'
700
+ ```
701
+
702
+ **Compiler rule:**
703
+ ```
704
+ if 'is on' or 'navigate to':
705
+ code = page.goto(value, { waitUntil: 'networkidle' })
706
+ if 'see':
707
+ code = expect(page).toHaveURL(regex_from_value)
708
+ ```
709
+
710
+ **Playwright output:**
711
+ ```typescript
712
+ // Given: navigate
713
+ await page.goto('/login', { waitUntil: 'networkidle' });
714
+
715
+ // navigate with data interpolation
716
+ await page.goto('/users/12345/profile', { waitUntil: 'networkidle' });
717
+
718
+ // Then: assert URL
719
+ await page.waitForLoadState('networkidle');
720
+ await expect(page).toHaveURL(/\/dashboard/);
721
+ ```
722
+
723
+ ---
724
+
725
+ ### ⑰ Text assertions: `see [Target] type contains/has text {{Value}}`
726
+
727
+ ```gherkin
728
+ Then User see [Message] text contains {{partial_text}}
729
+ Then User see [Counter] text has text {{exact_count}}
730
+ Then User see [Description] text has text {{full_description}}
731
+ ```
732
+
733
+ **Compiler rule:**
734
+ ```
735
+ locator = resolve([Target], type)
736
+ if 'contains': code = expect(locator).toContainText(value)
737
+ if 'has text': code = expect(locator).toHaveText(value)
738
+ ```
739
+
740
+ **Playwright output:**
741
+ ```typescript
742
+ // contains (partial match)
743
+ await expect(page.getByText('Message')).toContainText('successfully');
744
+
745
+ // has text (exact match)
746
+ await expect(locator).toHaveText('42');
747
+ ```
748
+
749
+ ---
750
+
751
+ ## Table-Specific Patterns (extended)
752
+
753
+ ### Row count
754
+
755
+ ```gherkin
756
+ Then User see [Users] table has {{row_count}} rows
757
+ ```
758
+
759
+ ```typescript
760
+ const table = page.getByRole('table', { name: 'Users' });
761
+ // subtract 1 for header row, or use tbody rows
762
+ await expect(table.locator('tbody').getByRole('row')).toHaveCount(10);
763
+ ```
764
+
765
+ ### Column exists
766
+
767
+ ```gherkin
768
+ Then User see [Users] table has [Email] column
769
+ ```
770
+
771
+ ```typescript
772
+ await expect(
773
+ page.getByRole('table', { name: 'Users' }).getByRole('columnheader', { name: 'Email' })
774
+ ).toBeVisible();
775
+ ```
776
+
777
+ ### Empty state
778
+
779
+ ```gherkin
780
+ Then User see [Users] table is empty
781
+ Then User see [Users] table empty message with {{no_data_text}}
782
+ ```
783
+
784
+ ```typescript
785
+ // empty: only header row
786
+ await expect(
787
+ page.getByRole('table', { name: 'Users' }).locator('tbody').getByRole('row')
788
+ ).toHaveCount(0);
789
+
790
+ // empty message
791
+ await expect(page.getByText('No data available')).toBeVisible();
792
+ ```
793
+
794
+ ### Sort column
795
+
796
+ ```gherkin
797
+ When User click [Name] columnheader
798
+ Then User see [Name] columnheader is sorted ascending
799
+ ```
800
+
801
+ ```typescript
802
+ await page.getByRole('columnheader', { name: 'Name' }).click();
803
+ await expect(
804
+ page.getByRole('columnheader', { name: 'Name' })
805
+ ).toHaveAttribute('aria-sort', 'ascending');
806
+ ```
807
+
808
+ ### Pagination
809
+
810
+ ```gherkin
811
+ When User click [Users] table next page
812
+ When User select [Users] table page size with {{page_size}}
813
+ Then User see [Users] table page info with {{page_info}}
814
+ ```
815
+
816
+ Uses `pagination` from selector YAML:
817
+ ```typescript
818
+ await page.locator('[data-testid="next-page"]').click();
819
+ await page.locator('[data-testid="page-size"]').selectOption('25');
820
+ await expect(page.locator('[data-testid="page-info"]')).toHaveText('Showing 1-25 of 100');
821
+ ```
822
+
823
+ ### Row expansion
824
+
825
+ ```gherkin
826
+ When User expand [Users] table row with {{user_name}}
827
+ When User collapse [Users] table row with {{user_name}}
828
+ ```
829
+
830
+ ```typescript
831
+ const row = table.getByRole('row').filter({ hasText: 'John' });
832
+ await row.getByRole('button', { name: 'Expand' }).click();
833
+ ```
834
+
835
+ ---
836
+
837
+ ## Dialog Scope
838
+
839
+ When a dialog is opened, subsequent steps are scoped within it:
840
+
841
+ ```gherkin
842
+ Then User see [panel] dialog with {{dialog_title}}
843
+ # All steps below are scoped to this dialog:
844
+ When User fill [Email] field with {{email}}
845
+ When User click [Submit] button
846
+ When User wait for [panel] dialog with {{dialog_title}} is hidden
847
+ # Scope ends
848
+ ```
849
+
850
+ **Compiler rule:** Track dialog context. When dialog is visible, wrap all locators:
851
+
852
+ ```typescript
853
+ const dialog = page.getByRole('dialog').filter({ hasText: 'Gửi lời cảm ơn' });
854
+ await expect(dialog).toBeVisible();
855
+
856
+ // Scoped steps
857
+ await dialog.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
858
+ await dialog.getByRole('button', { name: 'Submit' }).click();
859
+ await dialog.waitFor({ state: 'hidden' });
860
+ ```
861
+
862
+ ---
863
+
864
+ ## Frame Scope
865
+
866
+ Similar to dialog scope, but for iframes:
867
+
868
+ ```gherkin
869
+ When User switch to [Payment] frame
870
+ When User fill [Card number] field with {{card_number}}
871
+ When User fill [Expiry] field with {{expiry_date}}
872
+ When User click [Pay] button
873
+ When User switch to [main] frame
874
+ ```
875
+
876
+ **Compiler rule:** Track frame context.
877
+
878
+ ```typescript
879
+ const frame = page.frameLocator('#payment-iframe');
880
+ await frame.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
881
+ await frame.getByRole('textbox', { name: 'Expiry' }).fill('12/28');
882
+ await frame.getByRole('button', { name: 'Pay' }).click();
883
+ // switch to main = reset to page context
884
+ ```
885
+
886
+ ---
887
+
888
+ ## Element Type → Role Mapping
889
+
890
+ | Gherkin type | Playwright role | Locator method |
891
+ |---|---|---|
892
+ | `page` | — | `page.goto(value)` |
893
+ | `button` | `button` | `getByRole('button')` |
894
+ | `link` | `link` | `getByRole('link')` |
895
+ | `field` / `input` | `textbox` | `getByRole('textbox')` |
896
+ | `textarea` | `textbox` | `getByRole('textbox')` or locator for contenteditable |
897
+ | `heading` | `heading` | `getByRole('heading')` |
898
+ | `text` | — | `getByText(value)` |
899
+ | `image` / `img` / `icon` | `img` | `getByRole('img')` |
900
+ | `checkbox` | `checkbox` | `getByRole('checkbox')` |
901
+ | `radio` | `radio` | `getByRole('radio')` |
902
+ | `switch` / `toggle` | `switch` | `getByRole('switch')` |
903
+ | `dropdown` / `select` | `combobox` | `getByRole('combobox')` |
904
+ | `option` | `option` | `getByRole('option')` |
905
+ | `dialog` / `modal` | `dialog` | `getByRole('dialog')` |
906
+ | `menu` | `menu` | `getByRole('menu')` |
907
+ | `menuitem` | `menuitem` | `getByRole('menuitem')` |
908
+ | `tab` | `tab` | `getByRole('tab')` |
909
+ | `tabpanel` | `tabpanel` | `getByRole('tabpanel')` |
910
+ | `list` | `list` | `getByRole('list')` |
911
+ | `listitem` | `listitem` | `getByRole('listitem')` |
912
+ | `table` | `table` | `getByRole('table')` |
913
+ | `row` | `row` | `getByRole('row')` |
914
+ | `cell` | `cell` | `getByRole('cell')` |
915
+ | `column` / `columnheader` | `columnheader` | `getByRole('columnheader')` |
916
+ | `region` / `section` | `region` | `getByRole('region')` |
917
+ | `nav` / `navigation` | `navigation` | `getByRole('navigation')` |
918
+ | `banner` / `header` | `banner` | `getByRole('banner')` |
919
+ | `footer` | `contentinfo` | `getByRole('contentinfo')` |
920
+ | `alert` | `alert` | `getByRole('alert')` |
921
+ | `spinner` / `progressbar` | `progressbar` | `getByRole('progressbar')` |
922
+ | `slider` | `slider` | `getByRole('slider')` |
923
+ | `tree` | `tree` | `getByRole('tree')` |
924
+ | `treeitem` | `treeitem` | `getByRole('treeitem')` |
925
+ | `tooltip` | `tooltip` | `getByRole('tooltip')` |
926
+ | `uploader` / `file` | — | `setInputFiles()` |
927
+ | `frame` / `iframe` | — | `frameLocator()` |
928
+
929
+ ---
930
+
931
+ ## Compiler Rules Summary
932
+
933
+ ### Rule 1: Build locator from selector YAML
934
+
935
+ ```
936
+ locator = buildLocator(selector)
937
+
938
+ if selector.frame: base = page.frameLocator(frame)
939
+ elif dialogContext: base = dialogLocator
940
+ else: base = page
941
+
942
+ if selector.scope: base = base.getByLabel(scope)
943
+
944
+ switch selector.type:
945
+ testid → base.getByTestId(value)
946
+ role → base.getByRole(value, { name, exact })
947
+ label → base.getByLabel(value, { exact })
948
+ placeholder → base.getByPlaceholder(value, { exact })
949
+ text → base.getByText(value, { exact: match=='exact' })
950
+ locator → base.locator(value)
951
+ page → page.goto(value) or expect(page).toHaveURL()
952
+ upload → base.locator(value)
953
+ frame → page.frameLocator(value)
954
+ table → base.getByRole('table', { name: value })
955
+
956
+ if selector.nth > 0: locator = locator.nth(selector.nth)
957
+ ```
958
+
959
+ ### Rule 2: Map Gherkin verb to Playwright method
960
+
961
+ ```
962
+ click → .click()
963
+ double click → .dblclick()
964
+ fill → .fill(value) or .pressSequentially(value) if contenteditable
965
+ clear → .clear()
966
+ check → .check()
967
+ uncheck → .uncheck()
968
+ toggle → .click()
969
+ select (native) → .selectOption(value)
970
+ select (custom) → .click() then getByRole('option', {name}).click()
971
+ upload → .setInputFiles('specs/storage/' + value)
972
+ hover → .hover()
973
+ drag ... to → .dragTo(targetLocator)
974
+ press (global) → page.keyboard.press(key)
975
+ press (on elem) → locator.press(key)
976
+ scroll to → .scrollIntoViewIfNeeded()
977
+ expand → .getByRole('button', {name:'Expand'}).click()
978
+ collapse → .getByRole('button', {name:'Collapse'}).click()
979
+ ```
980
+
981
+ ### Rule 3: Map state to Playwright assertion
982
+
983
+ ```
984
+ see → expect(locator).toBeVisible()
985
+ see ... hidden → expect(locator).toBeHidden()
986
+ see ... visible → expect(locator).toBeVisible()
987
+ see ... enabled → expect(locator).toBeEnabled()
988
+ see ... disabled→ expect(locator).toBeDisabled()
989
+ see ... checked → expect(locator).toBeChecked()
990
+ see ... unchecked→ expect(locator).not.toBeChecked()
991
+ see ... focused → expect(locator).toBeFocused()
992
+ see ... empty → expect(locator).toHaveText('')
993
+ see ... loading → expect(locator).toBeVisible() (spinner/progressbar)
994
+ see ... selected→ expect(locator).toHaveAttribute('aria-selected', 'true')
995
+ see ... sorted ascending → expect(locator).toHaveAttribute('aria-sort', 'ascending')
996
+ see ... sorted descending → expect(locator).toHaveAttribute('aria-sort', 'descending')
997
+ contains → expect(locator).toContainText(value)
998
+ has text → expect(locator).toHaveText(value)
999
+ has (attribute) → expect(locator).toHaveAttribute(attr, value)
1000
+ has ... rows → expect(locator.getByRole('row')).toHaveCount(N)
1001
+ has ... column → expect(locator.getByRole('columnheader', {name})).toBeVisible()
1002
+ ```
1003
+
1004
+ ### Rule 4: Scope management
1005
+
1006
+ ```
1007
+ Dialog enter: "see [X] dialog with {{title}}"
1008
+ → dialogLocator = page.getByRole('dialog').filter({ hasText: title })
1009
+ → all subsequent locators use dialogLocator as base
1010
+
1011
+ Dialog exit: "wait for [X] dialog with {{title}} is hidden"
1012
+ → dialogLocator.waitFor({ state: 'hidden' })
1013
+ → reset to page context
1014
+
1015
+ Frame enter: "switch to [X] frame"
1016
+ → frameLocator = page.frameLocator(value)
1017
+ → all subsequent locators use frameLocator as base
1018
+
1019
+ Frame exit: "switch to [main] frame"
1020
+ → reset to page context
1021
+ ```
1022
+
1023
+ ### Rule 5: Table operations
1024
+
1025
+ ```
1026
+ Table locator: page.getByRole('table', { name: selector.value })
1027
+
1028
+ Row by index: table.getByRole('row').nth(N)
1029
+ Row by filter: table.getByRole('row').filter({ hasText: filterValue })
1030
+ Cell by col: row.getByRole('cell').nth(columns[colName].index)
1031
+ Action in row: row.resolve([element])
1032
+
1033
+ Count: expect(table.locator('tbody').getByRole('row')).toHaveCount(N)
1034
+ Empty: expect(table.locator('tbody').getByRole('row')).toHaveCount(0)
1035
+ Column exists: expect(table.getByRole('columnheader', { name })).toBeVisible()
1036
+ Sort: table.getByRole('columnheader', { name }).click()
1037
+ Pagination: page.locator(pagination.next).click()
1038
+ ```
1039
+
1040
+ ---
1041
+
1042
+ ## What AI Discover Must Output
1043
+
1044
+ When AI visits a page via MCP Playwright, it must produce YAML entries with:
1045
+
1046
+ 1. **Correct `type`** — detected from accessibility snapshot role
1047
+ 2. **Correct `name`** — from accessible name in snapshot
1048
+ 3. **`exact: true`** — when name is a substring of another element's name
1049
+ 4. **`scope`** — when element appears in multiple landmarks (header + footer)
1050
+ 5. **`match: exact`** — when text value is substring of other text on page
1051
+ 6. **`variant`** — for dropdowns: inspect if `<select>` or custom div
1052
+ 7. **`trigger`** — for uploads: find the visible button near hidden `input[type=file]`
1053
+ 8. **`contenteditable`** — for rich editors: check if `[contenteditable="true"]`
1054
+ 9. **`frame`** — for iframes: detect if element is inside a frame
1055
+ 10. **`columns`** — for tables: map column headers to indices
1056
+
1057
+ ## What AI Verify Must Fix
1058
+
1059
+ When tests fail, AI reads error messages and fixes YAML:
1060
+
1061
+ | Error pattern | YAML fix |
1062
+ |---|---|
1063
+ | `strict mode violation: resolved to N elements` | Add `exact: true` or `scope` or increase `nth` |
1064
+ | `element(s) not found` | Fix `name`, `type`, or `value` in YAML |
1065
+ | `resolved to N elements` (getByText) | Add `match: 'exact'` |
1066
+ | `not visible` / timeout | Element may need scroll, wait, or page didn't load (auth issue) |
1067
+ | `intercepted by overlay` | Need to close overlay first (press Escape, click backdrop) |
1068
+ | timeout on click | Element in iframe, behind overlay, or auth redirect |
1069
+ | `toHaveText` mismatch | Fix expected value in test-data.yaml |
1070
+ | `toHaveURL` mismatch | Fix page value in selectors.yaml |
1071
+ | `toHaveAttribute` mismatch | Fix attribute/pattern in selectors.yaml |