Skip to content

Commit 44a5cf2

Browse files
authored
Merge branch 'main' into a11y/rich-card-title-as-heading-option
2 parents 1cb023d + aa7d0fd commit 44a5cf2

7 files changed

Lines changed: 309 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
name: webchat-html-tests
3+
description: 'Run Bot Framework Web Chat HTML tests in Docker/Selenium Grid. Use when: running __tests__/html2, debugging failing HTML tests, updating snapshots, checking grid health, or cleaning leaked Selenium sessions.'
4+
argument-hint: 'Optional HTML test regex'
5+
---
6+
7+
# Web Chat HTML Tests
8+
9+
Run the HTML test harness, keep Selenium Grid healthy, and debug failures without keeping all of the operational detail in the skill body.
10+
11+
## When to Use
12+
13+
- Running `__tests__/html2` for a branch or PR
14+
- Debugging a failing HTML or snapshot test
15+
- Updating snapshots after an intentional visual change
16+
- Checking whether Selenium Grid is ready or leaking sessions
17+
18+
## Procedure
19+
20+
### 1. Start Selenium Grid
21+
22+
Use the bundled script instead of pasting long Docker commands.
23+
24+
For a focused test run, keep the default scale of 2 Chrome nodes:
25+
26+
```sh
27+
./.github/skills/webchat-html-tests/scripts/start-grid.sh
28+
```
29+
30+
For a full run, match Jest's 4 workers:
31+
32+
```sh
33+
CHROME_SCALE=4 ./.github/skills/webchat-html-tests/scripts/start-grid.sh
34+
```
35+
36+
### 2. Wait for Grid Readiness
37+
38+
```sh
39+
python3 ./.github/skills/webchat-html-tests/scripts/wait-for-grid.py
40+
```
41+
42+
Stop if the script times out or if the node summary does not show ready nodes.
43+
44+
### 3. Run Tests
45+
46+
Run the full suite:
47+
48+
```sh
49+
./.github/skills/webchat-html-tests/scripts/run-html-tests.sh
50+
```
51+
52+
Run a focused HTML test with a regex anchored to the exact file:
53+
54+
```sh
55+
./.github/skills/webchat-html-tests/scripts/run-html-tests.sh "__tests__/html2/activity/message-status\.html$"
56+
```
57+
58+
Update snapshots for an expected visual change:
59+
60+
```sh
61+
./.github/skills/webchat-html-tests/scripts/run-html-tests.sh --update "__tests__/html2/activity/message-status\.html$"
62+
```
63+
64+
### 4. Clean Grid Sessions After Every Run
65+
66+
```sh
67+
python3 ./.github/skills/webchat-html-tests/scripts/cleanup-grid-sessions.py
68+
```
69+
70+
If leaked sessions remain, clean them before the next Jest run or the grid can stall.
71+
72+
### 5. Recover Common Infra Problems
73+
74+
If dist files return 404s after a local build, restart `webchat2`:
75+
76+
```sh
77+
docker compose -f docker-compose-wsl2.yml restart webchat2
78+
```
79+
80+
If a failure is not obvious, load the reference docs before changing code:
81+
82+
- [Architecture and test layout](./references/architecture.md)
83+
- [Failure modes and snapshot workflow](./references/failure-modes.md)
84+
85+
### 6. Tear Down
86+
87+
```sh
88+
docker compose -f docker-compose-wsl2.yml down
89+
```
90+
91+
## Checklist
92+
93+
- [ ] Grid is ready before running Jest
94+
- [ ] Focused tests use an anchored regex when targeting one HTML file
95+
- [ ] Sessions are cleaned after every run
96+
- [ ] Snapshot updates are rerun without `--update`
97+
- [ ] `npm run precommit` passes before opening the PR
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Architecture and Test Layout
2+
3+
HTML tests live under `__tests__/html2/`.
4+
5+
Jest picks them up through the `html2` project declared in `jest.config.js`, then runs them in a WebDriver-backed environment against Selenium Grid.
6+
7+
## Key Pieces
8+
9+
- `docker-compose-wsl2.yml`: Selenium hub, Chrome nodes, `webchat2`, and `jest-server`
10+
- `jest.config.js`: top-level worker count and project wiring
11+
- `packages/test/harness/`: WebDriver environment, host bridge, and snapshot support
12+
- `packages/test/page-object/src/globals`: `host`, `pageConditions`, and `pageElements`
13+
14+
## Redirect Test Pattern
15+
16+
Many tests have a base file plus small redirect files for theme or variant coverage.
17+
18+
- Base file example: `message-status.html`
19+
- Redirect file example: `message-status.copilot.html`
20+
21+
Redirect files usually change `location` to the base test with query parameters such as:
22+
23+
- `?variant=fluent`
24+
- `?variant=copilot`
25+
- `?fluent-theme=dark`
26+
- `?variant=copilot&fluent-theme=dark`
27+
28+
When debugging a redirect failure, inspect the redirect target first because the real test logic usually lives there.
29+
30+
## Regex Targeting Reminder
31+
32+
`--testPathPattern` is a regex.
33+
34+
- Use `message-status\.html$` when you want only the base file
35+
- Use `message-status\.` when you want the base file plus its redirect variants
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Failure Modes and Snapshot Workflow
2+
3+
## Common Failures
4+
5+
| Error pattern | Likely cause | Recovery |
6+
| --------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
7+
| `Expected image to match or be a close match to snapshot` | Real visual change or regression | Inspect the diff PNG, then decide whether to update snapshots or fix the code |
8+
| Timeout or hanging Jest run | Selenium sessions leaked | Run `python3 ./.github/skills/webchat-html-tests/scripts/cleanup-grid-sessions.py` before rerunning |
9+
| `Failed to load resource: 404 (Not Found)` for dist files | `webchat2` serving stale or empty content | `docker compose -f docker-compose-wsl2.yml restart webchat2` |
10+
| `Cannot read properties of undefined (reading 'FluentThemeProvider')` | Fluent bundle returned 404 | Restart `webchat2` and confirm the local build completed |
11+
| `Cannot read properties of undefined (reading 'ReactWebChat')` | Main bundle returned 404 | Restart `webchat2` and confirm the local build completed |
12+
13+
## Snapshot Update Flow
14+
15+
1. Reproduce the failure without `--update`.
16+
2. Inspect the generated `.snap-N-diff.png` file next to the HTML test.
17+
3. Only if the change is intentional, rerun with `--update`.
18+
4. Rerun the same test again without `--update` to confirm the snapshot is now stable.
19+
5. Clean leaked Selenium sessions between runs.
20+
21+
## HTML Test Anatomy
22+
23+
Most HTML tests follow this structure:
24+
25+
- Load `/test-harness.js` and `/test-page-object.js`
26+
- Create a Direct Line emulator with `testHelpers.createDirectLineEmulator()`
27+
- Drive activities with `emulateIncomingActivity()` or `emulateOutgoingActivity()`
28+
- Assert through `pageConditions` and `pageElements`
29+
- Capture snapshots with `await host.snapshot('local')`
30+
- Use `expect`, not `assert`
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import sys
6+
import urllib.error
7+
import urllib.request
8+
9+
10+
GRID_REQUEST_TIMEOUT_SECONDS = float(os.environ.get('GRID_REQUEST_TIMEOUT_SECONDS', '5'))
11+
GRID_SESSION_URL = os.environ.get('GRID_SESSION_URL', 'http://localhost:4444/wd/hub/session')
12+
GRID_STATUS_URL = os.environ.get('GRID_STATUS_URL', 'http://localhost:4444/wd/hub/status')
13+
14+
15+
def fetch_status():
16+
with urllib.request.urlopen(GRID_STATUS_URL, timeout=GRID_REQUEST_TIMEOUT_SECONDS) as response:
17+
return json.load(response)
18+
19+
20+
def delete_session(session_id):
21+
request = urllib.request.Request(f'{GRID_SESSION_URL}/{session_id}', method='DELETE')
22+
23+
with urllib.request.urlopen(request, timeout=GRID_REQUEST_TIMEOUT_SECONDS):
24+
return
25+
26+
27+
def main() -> int:
28+
try:
29+
payload = fetch_status()
30+
except (OSError, urllib.error.URLError) as error:
31+
print(f'Failed to fetch Selenium Grid status: {error}', file=sys.stderr)
32+
return 1
33+
34+
busy_sessions = []
35+
36+
for node in payload.get('value', {}).get('nodes') or []:
37+
for slot in node.get('slots') or []:
38+
session = slot.get('session')
39+
session and busy_sessions.append(session.get('sessionId'))
40+
41+
print(f'Busy sessions: {len(busy_sessions)}')
42+
43+
for session_id in busy_sessions:
44+
try:
45+
delete_session(session_id)
46+
except (OSError, urllib.error.URLError) as error:
47+
print(f'Error deleting {session_id}: {error}', file=sys.stderr)
48+
continue
49+
50+
print(f'Deleted {session_id}')
51+
52+
return 0
53+
54+
55+
if __name__ == '__main__':
56+
sys.exit(main())
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
update_snapshots=false
6+
test_pattern=''
7+
8+
for argument in "$@"; do
9+
if [[ "$argument" == '--update' ]]; then
10+
update_snapshots=true
11+
elif [[ -z "$test_pattern" ]]; then
12+
test_pattern="$argument"
13+
else
14+
printf 'Usage: %s [--update] [test-path-regex]\n' "$0" >&2
15+
exit 1
16+
fi
17+
done
18+
19+
arguments=()
20+
21+
$update_snapshots && arguments+=('-u')
22+
[[ -n "$test_pattern" ]] && arguments+=('--testPathPattern' "$test_pattern")
23+
24+
npm test -- "${arguments[@]}"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
readonly registry="${REGISTRY:-mcr.microsoft.com}"
6+
readonly chrome_scale="${CHROME_SCALE:-2}"
7+
8+
docker compose -f docker-compose-wsl2.yml build --build-arg "REGISTRY=${registry}"
9+
docker compose -f docker-compose-wsl2.yml up --detach --scale "chrome=${chrome_scale}"
10+
docker compose -f docker-compose-wsl2.yml restart webchat2
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import sys
6+
import time
7+
import urllib.error
8+
import urllib.request
9+
10+
11+
GRID_URL = os.environ.get('GRID_URL', 'http://localhost:4444/wd/hub/status')
12+
POLL_INTERVAL_SECONDS = float(os.environ.get('GRID_POLL_INTERVAL_SECONDS', '2'))
13+
REQUEST_TIMEOUT_SECONDS = float(os.environ.get('GRID_REQUEST_TIMEOUT_SECONDS', '5'))
14+
TIMEOUT_SECONDS = float(os.environ.get('GRID_TIMEOUT_SECONDS', '60'))
15+
16+
17+
def fetch_status():
18+
with urllib.request.urlopen(GRID_URL, timeout=REQUEST_TIMEOUT_SECONDS) as response:
19+
return json.load(response)
20+
21+
22+
def print_summary(payload):
23+
value = payload.get('value') or {}
24+
message = value.get('message', 'No message returned')
25+
ready = value.get('ready', False)
26+
print(f'{message} [ready={ready}]')
27+
28+
for node in value.get('nodes') or []:
29+
node_id = node.get('id', '<unknown>')
30+
availability = node.get('availability', '<unknown>')
31+
print(f'node {node_id} {availability}')
32+
33+
34+
deadline = time.monotonic() + TIMEOUT_SECONDS
35+
last_payload = None
36+
37+
while time.monotonic() < deadline:
38+
try:
39+
payload = fetch_status()
40+
except (OSError, urllib.error.URLError) as error:
41+
print(f'Waiting for Selenium Grid: {error}', file=sys.stderr)
42+
time.sleep(POLL_INTERVAL_SECONDS)
43+
continue
44+
45+
last_payload = payload
46+
value = payload.get('value') or {}
47+
48+
if value.get('ready'):
49+
print_summary(payload)
50+
sys.exit(0)
51+
52+
print_summary(payload)
53+
time.sleep(POLL_INTERVAL_SECONDS)
54+
55+
print('Timed out waiting for Selenium Grid readiness.', file=sys.stderr)
56+
last_payload and print_summary(last_payload)
57+
sys.exit(1)

0 commit comments

Comments
 (0)