code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E smoke tests for the coord phone webapp.
|
|
3
|
+
*
|
|
4
|
+
* These form the **Playwright acceptance gate** called out in issue #741 and
|
|
5
|
+
* CLAUDE.md. The suite drives a real headless browser against the Vite dev
|
|
6
|
+
* server; API calls are intercepted by Playwright's `page.route()` so no live
|
|
7
|
+
* Python daemon is required.
|
|
8
|
+
*
|
|
9
|
+
* Run: npm run test:e2e
|
|
10
|
+
*
|
|
11
|
+
* Prerequisites (one-time per machine):
|
|
12
|
+
* npx playwright install chromium
|
|
13
|
+
*
|
|
14
|
+
* Add new tests here as the webapp grows — one test per new behaviour-changing
|
|
15
|
+
* PR. Coverage should ratchet upward, never shrink.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test, expect, type Page } from '@playwright/test'
|
|
19
|
+
|
|
20
|
+
// ── Shared seed data (mirrors BoardData / PipelineView shapes) ────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A minimal /api/pipeline response with two active items and one completed.
|
|
24
|
+
* Mirrors the `make_test_app(BoardData)` pattern from coord-tui.
|
|
25
|
+
*
|
|
26
|
+
* #1724: every real item has FOUR stages — coding, smoke ("test" gate,
|
|
27
|
+
* ordered before review per config.pipeline.default_gates =
|
|
28
|
+
* ["test","review","merge"]), review, merge. This fixture used to have only
|
|
29
|
+
* three (no smoke/test stage at all), so it never rendered a Test pill and
|
|
30
|
+
* was blind to both #1724 defects (the Test pill stuck "skipped", and the
|
|
31
|
+
* displayed order having Review before Test). Kept at four stages, in the
|
|
32
|
+
* real order, so this fixture stays representative.
|
|
33
|
+
*/
|
|
34
|
+
const SEEDED_PIPELINE = [
|
|
35
|
+
{
|
|
36
|
+
assignment_id: 'work-1',
|
|
37
|
+
issue_number: 42,
|
|
38
|
+
issue_title: 'Fix the dashboard rendering',
|
|
39
|
+
repo_name: 'api',
|
|
40
|
+
machine_name: 'laptop',
|
|
41
|
+
current_stage: 'coding',
|
|
42
|
+
stages: [
|
|
43
|
+
{ name: 'coding', status: 'active', is_current: true },
|
|
44
|
+
{ name: 'smoke', status: 'waiting', is_current: false },
|
|
45
|
+
{ name: 'review', status: 'waiting', is_current: false },
|
|
46
|
+
{ name: 'merge', status: 'waiting', is_current: false },
|
|
47
|
+
],
|
|
48
|
+
available_gates: [],
|
|
49
|
+
progress_pct: 20,
|
|
50
|
+
review_findings_pending: false,
|
|
51
|
+
review_verdict: null,
|
|
52
|
+
review_findings_body: null,
|
|
53
|
+
test_verdict: null,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
assignment_id: 'work-2',
|
|
57
|
+
issue_number: 99,
|
|
58
|
+
issue_title: 'Refactor merge queue',
|
|
59
|
+
repo_name: 'api',
|
|
60
|
+
machine_name: 'server',
|
|
61
|
+
current_stage: 'review_running',
|
|
62
|
+
stages: [
|
|
63
|
+
{ name: 'coding', status: 'completed', is_current: false },
|
|
64
|
+
{ name: 'smoke', status: 'completed', is_current: false },
|
|
65
|
+
{ name: 'review', status: 'active', is_current: true },
|
|
66
|
+
{ name: 'merge', status: 'waiting', is_current: false },
|
|
67
|
+
],
|
|
68
|
+
available_gates: [],
|
|
69
|
+
progress_pct: 60,
|
|
70
|
+
review_findings_pending: false,
|
|
71
|
+
review_verdict: null,
|
|
72
|
+
review_findings_body: null,
|
|
73
|
+
test_verdict: 'passed',
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install API route mocks on the page so no live backend is required.
|
|
79
|
+
* Call this at the top of every test that navigates to the webapp.
|
|
80
|
+
*/
|
|
81
|
+
async function mockApi(page: Page): Promise<void> {
|
|
82
|
+
// Pipeline list
|
|
83
|
+
await page.route('**/api/pipeline', async route => {
|
|
84
|
+
await route.fulfill({
|
|
85
|
+
status: 200,
|
|
86
|
+
contentType: 'application/json',
|
|
87
|
+
body: JSON.stringify(SEEDED_PIPELINE),
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
// Board (used by some views)
|
|
91
|
+
await page.route('**/api/board', async route => {
|
|
92
|
+
await route.fulfill({
|
|
93
|
+
status: 200,
|
|
94
|
+
contentType: 'application/json',
|
|
95
|
+
body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
test.describe('coord webapp smoke suite (#741)', () => {
|
|
103
|
+
/**
|
|
104
|
+
* Pinned to a phone viewport (#1547).
|
|
105
|
+
*
|
|
106
|
+
* This suite is, and always was, the *phone* app's regression net — it was
|
|
107
|
+
* simply running at Playwright's `Desktop Chrome` default (1280×720) by
|
|
108
|
+
* accident, because until the responsive shell landed the app rendered the
|
|
109
|
+
* same single column at every width. Now 1280px is the wide layout, where
|
|
110
|
+
* list and detail are both on screen and "clicking a card opens its detail
|
|
111
|
+
* view" would resolve the issue title twice and trip Playwright's strict
|
|
112
|
+
* mode. Making the viewport explicit says what these three tests are for;
|
|
113
|
+
* the wide and mid layouts get their own coverage in `shell.spec.ts`.
|
|
114
|
+
*/
|
|
115
|
+
test.use({ viewport: { width: 390, height: 844 } })
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Core smoke 1: the home screen must render at least one PipelineCard for
|
|
119
|
+
* each active item in the seeded /api/pipeline response.
|
|
120
|
+
*
|
|
121
|
+
* This guards the full data-fetch → React render → DOM paint path.
|
|
122
|
+
* A structural rename of `PipelineCard` or `fetchPipeline()` that breaks
|
|
123
|
+
* the render would fail here.
|
|
124
|
+
*/
|
|
125
|
+
test('home screen renders pipeline cards from seeded API', async ({ page }) => {
|
|
126
|
+
await mockApi(page)
|
|
127
|
+
await page.goto('/')
|
|
128
|
+
|
|
129
|
+
// Both active items must appear as cards.
|
|
130
|
+
await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
|
|
131
|
+
await expect(page.getByText('Refactor merge queue')).toBeVisible()
|
|
132
|
+
|
|
133
|
+
// Issue numbers must be visible.
|
|
134
|
+
await expect(page.getByText('#42')).toBeVisible()
|
|
135
|
+
await expect(page.getByText('#99')).toBeVisible()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Core smoke 2: the "Active" filter tab must be the default, and the
|
|
140
|
+
* "Needs me" tab must switch the visible set to only items with available
|
|
141
|
+
* gate actions. With the seeded data (no gates), "Needs me" renders empty.
|
|
142
|
+
*
|
|
143
|
+
* Guards the filter-tab click → state update → re-render path.
|
|
144
|
+
*/
|
|
145
|
+
test('filter tabs switch between Active and Needs-me', async ({ page }) => {
|
|
146
|
+
await mockApi(page)
|
|
147
|
+
await page.goto('/')
|
|
148
|
+
|
|
149
|
+
// Default: both items visible under "Active".
|
|
150
|
+
await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
|
|
151
|
+
|
|
152
|
+
// Click "Needs me" — seeded items have no gates → list should be empty.
|
|
153
|
+
// The filter tabs render with role="tab" (inside role="tablist").
|
|
154
|
+
await page.getByRole('tab', { name: /needs.me/i }).click()
|
|
155
|
+
|
|
156
|
+
// Neither card should be visible.
|
|
157
|
+
await expect(page.getByText('Fix the dashboard rendering')).not.toBeVisible()
|
|
158
|
+
await expect(page.getByText('Refactor merge queue')).not.toBeVisible()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Core smoke 3: clicking a pipeline card must navigate to the Detail view
|
|
163
|
+
* for that issue. The URL must be the repo/issue route (#1548), and the
|
|
164
|
+
* detail panel must render the issue title.
|
|
165
|
+
*
|
|
166
|
+
* Guards the click → react-router navigate → Detail component mount path.
|
|
167
|
+
*/
|
|
168
|
+
test('clicking a pipeline card opens its detail view', async ({ page }) => {
|
|
169
|
+
await mockApi(page)
|
|
170
|
+
// Also mock the diff endpoint so the Detail view doesn't get a network error.
|
|
171
|
+
await page.route('**/api/diff/**', async route => {
|
|
172
|
+
await route.fulfill({
|
|
173
|
+
status: 200,
|
|
174
|
+
contentType: 'application/json',
|
|
175
|
+
body: JSON.stringify({ diff: '', source: 'compare' }),
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await page.goto('/')
|
|
180
|
+
|
|
181
|
+
// Click the first card.
|
|
182
|
+
await page.getByText('Fix the dashboard rendering').click()
|
|
183
|
+
|
|
184
|
+
// The URL must be the addressable repo/issue route (#1548), not the
|
|
185
|
+
// assignment id — an issue outlives any one assignment.
|
|
186
|
+
await expect(page).toHaveURL(/\/pipeline\/api\/42/)
|
|
187
|
+
|
|
188
|
+
// The detail view must render the issue title.
|
|
189
|
+
await expect(page.getByText('Fix the dashboard rendering')).toBeVisible()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for the phone terminal takeover flow (#1072).
|
|
3
|
+
*
|
|
4
|
+
* Drives the complete open → PTY output renders → key-bar key → detach
|
|
5
|
+
* flow against a fake WebSocket — no real ssh/tmux or Python daemon required.
|
|
6
|
+
*
|
|
7
|
+
* Uses page.routeWebSocket() (Playwright ≥ 1.48) to intercept
|
|
8
|
+
* /ws/terminal/{session_id}: the async handler keeps the connection alive,
|
|
9
|
+
* echoes a banner, and records incoming keystrokes. The async form is
|
|
10
|
+
* required: if the handler returns synchronously Playwright auto-closes the
|
|
11
|
+
* WS, which also triggers React 18 StrictMode's double-invoke cleanup.
|
|
12
|
+
*
|
|
13
|
+
* Run: npm run test:e2e
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test, expect } from '@playwright/test'
|
|
17
|
+
|
|
18
|
+
// ── Seeded data ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const SESSION_ID = 'work-abc-1'
|
|
21
|
+
|
|
22
|
+
/** One in-progress interactive session surfaced by GET /api/sessions. */
|
|
23
|
+
const SEEDED_SESSION = {
|
|
24
|
+
session_id: SESSION_ID,
|
|
25
|
+
session_name: `coord-${SESSION_ID}`,
|
|
26
|
+
machine: 'laptop',
|
|
27
|
+
host: null,
|
|
28
|
+
repo: 'api',
|
|
29
|
+
issue: 42,
|
|
30
|
+
issue_title: 'Implement terminal takeover',
|
|
31
|
+
stage: 'work',
|
|
32
|
+
status: 'running',
|
|
33
|
+
attached: false,
|
|
34
|
+
pane_dead: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Banner the fake PTY sends immediately on connect.
|
|
39
|
+
* Chosen to be unambiguous in the xterm-rows DOM (no HTML metacharacters).
|
|
40
|
+
*/
|
|
41
|
+
const FAKE_PTY_BANNER = 'PTY-E2E-BANNER-OK\r\n'
|
|
42
|
+
|
|
43
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Silence /api/pipeline and /api/board so Home renders without errors. */
|
|
46
|
+
async function mockBasicApis(page: import('@playwright/test').Page): Promise<void> {
|
|
47
|
+
await page.route('**/api/pipeline', (route) =>
|
|
48
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
|
|
49
|
+
)
|
|
50
|
+
await page.route('**/api/board', (route) =>
|
|
51
|
+
route.fulfill({
|
|
52
|
+
status: 200,
|
|
53
|
+
contentType: 'application/json',
|
|
54
|
+
body: JSON.stringify({ round_number: 1, active: [], completed: [] }),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register an async fake-PTY WebSocket route for SESSION_ID.
|
|
61
|
+
*
|
|
62
|
+
* The handler is async and awaits a Promise that resolves when the browser
|
|
63
|
+
* closes the WS — this is critical: if the handler returns synchronously,
|
|
64
|
+
* Playwright closes the route (and therefore the WS) immediately, which
|
|
65
|
+
* causes React 18's StrictMode double-invoke to see both mounts hit a
|
|
66
|
+
* "Disconnected" state before the test can observe "Live".
|
|
67
|
+
*
|
|
68
|
+
* With the async handler, the lifecycle is:
|
|
69
|
+
* StrictMode mount #1 → WS opens → handler awaits → StrictMode cleanup
|
|
70
|
+
* closes WS → onClose callback fires → Promise resolves → handler returns
|
|
71
|
+
* StrictMode mount #2 → WS opens → handler awaits → WS stays alive
|
|
72
|
+
* (no more cleanups) → test proceeds normally
|
|
73
|
+
*
|
|
74
|
+
* Returns shared state updated by the route callbacks.
|
|
75
|
+
*/
|
|
76
|
+
async function routeFakePty(
|
|
77
|
+
page: import('@playwright/test').Page,
|
|
78
|
+
opts: { banner?: string } = {},
|
|
79
|
+
): Promise<{ receivedMessages: (string | Buffer)[]; closeCount: number[] }> {
|
|
80
|
+
const banner = opts.banner ?? FAKE_PTY_BANNER
|
|
81
|
+
const receivedMessages: (string | Buffer)[] = []
|
|
82
|
+
const closeCount = [0] // boxed so the route callback mutates it in-place
|
|
83
|
+
|
|
84
|
+
await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
|
|
85
|
+
// Clear per-connection state so each StrictMode re-mount starts fresh.
|
|
86
|
+
receivedMessages.length = 0
|
|
87
|
+
|
|
88
|
+
ws.send(banner)
|
|
89
|
+
ws.onMessage((msg) => receivedMessages.push(msg))
|
|
90
|
+
|
|
91
|
+
// Await closure — this keeps the WS alive until the browser closes it.
|
|
92
|
+
await new Promise<void>((resolve) => {
|
|
93
|
+
ws.onClose(() => {
|
|
94
|
+
closeCount[0]++
|
|
95
|
+
resolve()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return { receivedMessages, closeCount }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
test.describe('terminal takeover flow (#1072)', () => {
|
|
106
|
+
/**
|
|
107
|
+
* Core flow: home → session card → terminal pane → PTY output visible →
|
|
108
|
+
* key-bar Esc → byte reaches PTY → close → clean detach (WS closed, no
|
|
109
|
+
* tmux-session kill).
|
|
110
|
+
*
|
|
111
|
+
* Guards the entire open-terminal path as described in the issue:
|
|
112
|
+
* - SessionCard click navigates to /terminal/:id
|
|
113
|
+
* - Binary bytes pushed by the fake PTY appear in the xterm DOM
|
|
114
|
+
* - MobileKeyBar soft keys transmit the correct byte sequences
|
|
115
|
+
* - Closing the pane fires a clean WebSocket close (= detach) rather than
|
|
116
|
+
* an abrupt drop, and navigates the user back to the home screen.
|
|
117
|
+
*
|
|
118
|
+
* The "session NOT killed" invariant is enforced server-side by
|
|
119
|
+
* `attached.detach()` never calling `tmux kill-session` (#1065); here we
|
|
120
|
+
* verify the client-side half: ws.close() is called (clean close) rather
|
|
121
|
+
* than the page being destroyed, which is what the server's `finally` block
|
|
122
|
+
* relies on to trigger detach().
|
|
123
|
+
*/
|
|
124
|
+
test('open-terminal → PTY output renders → key-bar Esc → detach on close', async ({
|
|
125
|
+
page,
|
|
126
|
+
}) => {
|
|
127
|
+
// ── 1. Seed REST APIs ────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
await mockBasicApis(page)
|
|
130
|
+
|
|
131
|
+
// The home screen Live sessions section calls GET /api/sessions.
|
|
132
|
+
await page.route('**/api/sessions', (route) =>
|
|
133
|
+
route.fulfill({
|
|
134
|
+
status: 200,
|
|
135
|
+
contentType: 'application/json',
|
|
136
|
+
body: JSON.stringify([SEEDED_SESSION]),
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// ── 2. Intercept the WebSocket with a fake PTY ───────────────────────────
|
|
141
|
+
|
|
142
|
+
const { receivedMessages, closeCount } = await routeFakePty(page)
|
|
143
|
+
|
|
144
|
+
// ── 3. Home screen: Live sessions section must show the session card ─────
|
|
145
|
+
|
|
146
|
+
await page.goto('/')
|
|
147
|
+
|
|
148
|
+
// The SessionCard renders the issue_title as its button text.
|
|
149
|
+
const sessionCard = page.getByRole('button', { name: 'Implement terminal takeover' })
|
|
150
|
+
await expect(sessionCard).toBeVisible()
|
|
151
|
+
|
|
152
|
+
// The "live" status badge (lowercase, exact to avoid matching the "Live
|
|
153
|
+
// sessions" section heading — strict mode forbids multi-element matches).
|
|
154
|
+
await expect(page.getByText('live', { exact: true })).toBeVisible()
|
|
155
|
+
|
|
156
|
+
// Clicking the card navigates to /terminal/:sessionId.
|
|
157
|
+
await sessionCard.click()
|
|
158
|
+
|
|
159
|
+
// ── 4. Terminal view: URL and status label ───────────────────────────────
|
|
160
|
+
|
|
161
|
+
await expect(page).toHaveURL(new RegExp(SESSION_ID))
|
|
162
|
+
|
|
163
|
+
// "Live" appears once the WebSocket opens (ws.onopen → setState('open')).
|
|
164
|
+
// The async route handler keeps WS #2 alive through React StrictMode's
|
|
165
|
+
// double-invoke so this state is stable by the time we assert it.
|
|
166
|
+
await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
|
|
167
|
+
|
|
168
|
+
// ── 5. PTY output must render in the xterm DOM ───────────────────────────
|
|
169
|
+
|
|
170
|
+
// xterm.js (v6, DOM renderer — no renderer addon loaded) writes each
|
|
171
|
+
// character into spans inside .xterm-rows. toContainText() walks the full
|
|
172
|
+
// subtree so it finds the text even when it's split across cells.
|
|
173
|
+
await expect(page.locator('[data-testid="xterm-container"]')).toContainText(
|
|
174
|
+
'PTY-E2E-BANNER-OK',
|
|
175
|
+
{ timeout: 5_000 },
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// ── 6. MobileKeyBar: Esc key must reach the fake PTY ─────────────────────
|
|
179
|
+
|
|
180
|
+
await page.getByRole('button', { name: 'Escape' }).click()
|
|
181
|
+
|
|
182
|
+
// The Esc key sends '\x1b' via TextEncoder → binary WS frame.
|
|
183
|
+
// Playwright delivers binary frames as Buffers. poll() re-evaluates
|
|
184
|
+
// until the message arrives (short round-trip delay through the WS
|
|
185
|
+
// routing layer).
|
|
186
|
+
await expect
|
|
187
|
+
.poll(
|
|
188
|
+
() =>
|
|
189
|
+
receivedMessages.some((m) => {
|
|
190
|
+
const buf: Buffer = typeof m === 'string' ? Buffer.from(m, 'utf8') : (m as Buffer)
|
|
191
|
+
return buf.includes(0x1b) // ESC byte
|
|
192
|
+
}),
|
|
193
|
+
{ timeout: 2_000, message: 'expected ESC (0x1b) byte in received WS messages' },
|
|
194
|
+
)
|
|
195
|
+
.toBe(true)
|
|
196
|
+
|
|
197
|
+
// ── 7. Close → clean detach ──────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
// The ✕ button in Terminal.tsx calls ws.close() (detach) then navigate(-1).
|
|
200
|
+
// We verify: (a) another WS close event fires, (b) URL reverts.
|
|
201
|
+
const closeCountBefore = closeCount[0]
|
|
202
|
+
|
|
203
|
+
await page.getByRole('button', { name: 'Close terminal' }).click()
|
|
204
|
+
|
|
205
|
+
// A new WS close must happen beyond any StrictMode-induced close.
|
|
206
|
+
await expect
|
|
207
|
+
.poll(
|
|
208
|
+
() => closeCount[0] > closeCountBefore,
|
|
209
|
+
{ timeout: 2_000, message: 'expected WebSocket close event after clicking ✕' },
|
|
210
|
+
)
|
|
211
|
+
.toBe(true)
|
|
212
|
+
|
|
213
|
+
// navigate(-1) must take the user back to the home screen.
|
|
214
|
+
await expect(page).not.toHaveURL(new RegExp(SESSION_ID))
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Session-ended path: when the bridge closes the WebSocket with the
|
|
219
|
+
* "session is gone for good" code (4404 / SESSION_GONE_CODE — see
|
|
220
|
+
* coord/dashboard/server.py:terminal_ws, sent when resolve_session_target
|
|
221
|
+
* no longer finds a live assignment), the status label must flip to
|
|
222
|
+
* "Session ended".
|
|
223
|
+
*
|
|
224
|
+
* Guards the ws.onclose → (event.code === 4404) → setState('ended') →
|
|
225
|
+
* STATUS_LABEL['ended'] path in Terminal.tsx — the user should always know
|
|
226
|
+
* when the session has ended for good rather than seeing a frozen terminal
|
|
227
|
+
* with no feedback. 4404 is a terminal state: unlike other closes it is NOT
|
|
228
|
+
* retried, so this asserts the deterministic end-of-session feedback (a
|
|
229
|
+
* plain close, by contrast, is a transient drop → "Reconnecting…").
|
|
230
|
+
*/
|
|
231
|
+
test('terminal shows "Session ended" when the bridge closes with 4404', async ({
|
|
232
|
+
page,
|
|
233
|
+
}) => {
|
|
234
|
+
// Fake bridge that delivers one line then closes with the session-gone
|
|
235
|
+
// code (4404), the way server.py signals a session that no longer resolves.
|
|
236
|
+
await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
|
|
237
|
+
ws.send('session has ended\r\n')
|
|
238
|
+
|
|
239
|
+
// Await a timer then close — gives the banner time to land before the
|
|
240
|
+
// close frame races it in the browser's event loop.
|
|
241
|
+
await new Promise<void>((resolve) => {
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
ws.close({ code: 4404 })
|
|
244
|
+
resolve()
|
|
245
|
+
}, 80)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Navigate directly to the terminal view (no need to go through Home).
|
|
250
|
+
await page.goto(`/terminal/${SESSION_ID}`)
|
|
251
|
+
|
|
252
|
+
// The terminal must surface the end-of-session state once the 4404 close
|
|
253
|
+
// arrives (ws.onclose → event.code === SESSION_GONE_CODE → setState('ended')):
|
|
254
|
+
// a role="status" overlay tells the user the session is gone for good
|
|
255
|
+
// instead of leaving a frozen pane. ("Session ended" also appears in the
|
|
256
|
+
// header status label, so scope to the overlay to stay unambiguous.)
|
|
257
|
+
await expect(page.getByRole('status')).toContainText('Session ended', {
|
|
258
|
+
timeout: 5_000,
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reconnect path: a plain (non-4404) server close is a *transient* drop, so
|
|
264
|
+
* the client must surface "Reconnecting…" and retry with backoff — never a
|
|
265
|
+
* terminal state. This is the exact behavior the Terminal.tsx onclose guards
|
|
266
|
+
* protect (the `intentionalCloseRef` reset in connect() + the superseded-
|
|
267
|
+
* socket `wsRef.current !== ws` check): before that fix, React StrictMode's
|
|
268
|
+
* dev remount left the surviving socket's onclose short-circuited, so a real
|
|
269
|
+
* drop froze the pane at "Live" and never reconnected. Complements the 4404
|
|
270
|
+
* "Session ended" (terminal, no-retry) test above — this is the transient,
|
|
271
|
+
* retrying counterpart, and it fails-on-revert of the connect() reset.
|
|
272
|
+
*/
|
|
273
|
+
test('terminal shows "Reconnecting…" after a plain (transient) server close', async ({
|
|
274
|
+
page,
|
|
275
|
+
}) => {
|
|
276
|
+
// Fake bridge that drops each connection with a plain close shortly after
|
|
277
|
+
// it opens. A plain close (no 4404) is a transient drop, so the client
|
|
278
|
+
// keeps cycling into "Reconnecting…" (→ backoff → retry) rather than a
|
|
279
|
+
// terminal state — reliably observable within the timeout.
|
|
280
|
+
await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
|
|
281
|
+
ws.send(FAKE_PTY_BANNER)
|
|
282
|
+
await new Promise<void>((resolve) => {
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
ws.close() // plain close (no code) => transient => must reconnect
|
|
285
|
+
resolve()
|
|
286
|
+
}, 120)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Navigate directly to the terminal view (no need to go through Home).
|
|
291
|
+
await page.goto(`/terminal/${SESSION_ID}`)
|
|
292
|
+
|
|
293
|
+
// The status label flips to "Reconnecting…" once the surviving connection's
|
|
294
|
+
// onclose reaches the transient-drop branch (setState('reconnecting')).
|
|
295
|
+
// "Reconnecting…" appears only in the header label (no overlay), so a plain
|
|
296
|
+
// text locator is unambiguous.
|
|
297
|
+
await expect(page.getByText('Reconnecting…')).toBeVisible({ timeout: 5_000 })
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Scroll mode flow (#1299): Scroll → Page Up → Exit.
|
|
302
|
+
*
|
|
303
|
+
* Guards the full copy-mode round-trip from the phone's perspective:
|
|
304
|
+
* 1. Tap "Scroll" → fake WS receives `{"type":"copy-mode","action":"enter"}`
|
|
305
|
+
* 2. Tap "Page Up" → `{"type":"copy-mode","action":"page-up"}`
|
|
306
|
+
* 3. Tap "Exit Scroll" → `{"type":"copy-mode","action":"exit"}`
|
|
307
|
+
*
|
|
308
|
+
* Captures text frames (JSON) separately from binary frames (keystrokes) so
|
|
309
|
+
* the assertion is unambiguous. The harness runs against Vite dev with React
|
|
310
|
+
* StrictMode double-mount active; `routeFakePty` already handles that
|
|
311
|
+
* correctly (see the routeFakePty docstring above).
|
|
312
|
+
*/
|
|
313
|
+
test('Scroll mode: enter → page-up → exit sends correct copy-mode frames', async ({
|
|
314
|
+
page,
|
|
315
|
+
}) => {
|
|
316
|
+
// Fake PTY that records both binary and text frames.
|
|
317
|
+
const textFrames: string[] = []
|
|
318
|
+
|
|
319
|
+
await page.routeWebSocket(`**/ws/terminal/${SESSION_ID}`, async (ws) => {
|
|
320
|
+
textFrames.length = 0 // reset per StrictMode re-mount
|
|
321
|
+
|
|
322
|
+
ws.send(FAKE_PTY_BANNER)
|
|
323
|
+
ws.onMessage((msg) => {
|
|
324
|
+
if (typeof msg === 'string') {
|
|
325
|
+
textFrames.push(msg)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await new Promise<void>((resolve) => {
|
|
330
|
+
ws.onClose(() => resolve())
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
await page.goto(`/terminal/${SESSION_ID}`)
|
|
335
|
+
|
|
336
|
+
// Wait for live connection before interacting.
|
|
337
|
+
await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
|
|
338
|
+
|
|
339
|
+
// 1. Tap Scroll — enters copy-mode.
|
|
340
|
+
await page.getByRole('button', { name: 'Scroll' }).click()
|
|
341
|
+
|
|
342
|
+
await expect
|
|
343
|
+
.poll(
|
|
344
|
+
() => textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'enter' })),
|
|
345
|
+
{ timeout: 2_000, message: 'expected copy-mode enter frame' },
|
|
346
|
+
)
|
|
347
|
+
.toBe(true)
|
|
348
|
+
|
|
349
|
+
// SCROLL badge must appear.
|
|
350
|
+
await expect(page.getByRole('status', { name: 'Scroll mode active' })).toBeVisible()
|
|
351
|
+
|
|
352
|
+
// 2. Tap Page Up.
|
|
353
|
+
await page.getByRole('button', { name: 'Page Up' }).click()
|
|
354
|
+
|
|
355
|
+
await expect
|
|
356
|
+
.poll(
|
|
357
|
+
() =>
|
|
358
|
+
textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'page-up' })),
|
|
359
|
+
{ timeout: 2_000, message: 'expected copy-mode page-up frame' },
|
|
360
|
+
)
|
|
361
|
+
.toBe(true)
|
|
362
|
+
|
|
363
|
+
// 3. Tap Exit Scroll — exits copy-mode and hides badge.
|
|
364
|
+
await page.getByRole('button', { name: 'Exit Scroll' }).click()
|
|
365
|
+
|
|
366
|
+
await expect
|
|
367
|
+
.poll(
|
|
368
|
+
() => textFrames.some((f) => f === JSON.stringify({ type: 'copy-mode', action: 'exit' })),
|
|
369
|
+
{ timeout: 2_000, message: 'expected copy-mode exit frame' },
|
|
370
|
+
)
|
|
371
|
+
.toBe(true)
|
|
372
|
+
|
|
373
|
+
// Badge must be gone; normal Scroll button is back.
|
|
374
|
+
await expect(page.getByRole('status', { name: 'Scroll mode active' })).not.toBeVisible()
|
|
375
|
+
await expect(page.getByRole('button', { name: 'Scroll' })).toBeVisible()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Key-bar completeness: all four arrow keys must transmit their ANSI escape
|
|
380
|
+
* sequences to the fake PTY.
|
|
381
|
+
*
|
|
382
|
+
* Guards the four arrow SoftKeys in MobileKeyBar and their KEY_BYTES mappings
|
|
383
|
+
* (\x1b[A/B/C/D). A single key being wired to the wrong sequence, or the
|
|
384
|
+
* onSend prop not being threaded correctly, would fail here without cluttering
|
|
385
|
+
* the main flow test.
|
|
386
|
+
*/
|
|
387
|
+
test('MobileKeyBar arrow keys transmit correct ANSI sequences', async ({ page }) => {
|
|
388
|
+
const { receivedMessages } = await routeFakePty(page, { banner: 'ready\r\n' })
|
|
389
|
+
|
|
390
|
+
await page.goto(`/terminal/${SESSION_ID}`)
|
|
391
|
+
|
|
392
|
+
// Wait for the WS to be open (state = 'Live') before sending keys.
|
|
393
|
+
await expect(page.getByText('Live')).toBeVisible({ timeout: 5_000 })
|
|
394
|
+
|
|
395
|
+
const arrowCases: Array<{ label: string; seq: number[] }> = [
|
|
396
|
+
{ label: 'Up', seq: [0x1b, 0x5b, 0x41] }, // \x1b[A
|
|
397
|
+
{ label: 'Down', seq: [0x1b, 0x5b, 0x42] }, // \x1b[B
|
|
398
|
+
{ label: 'Right', seq: [0x1b, 0x5b, 0x43] }, // \x1b[C
|
|
399
|
+
{ label: 'Left', seq: [0x1b, 0x5b, 0x44] }, // \x1b[D
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
for (const { label, seq } of arrowCases) {
|
|
403
|
+
const before = receivedMessages.length
|
|
404
|
+
await page.getByRole('button', { name: label }).click()
|
|
405
|
+
|
|
406
|
+
await expect
|
|
407
|
+
.poll(
|
|
408
|
+
() => {
|
|
409
|
+
const fresh = receivedMessages.slice(before)
|
|
410
|
+
return fresh.some((m) => {
|
|
411
|
+
const buf: Buffer = typeof m === 'string' ? Buffer.from(m, 'utf8') : (m as Buffer)
|
|
412
|
+
return seq.every((byte, i) => buf[i] === byte)
|
|
413
|
+
})
|
|
414
|
+
},
|
|
415
|
+
{ timeout: 2_000, message: `expected ${label} arrow ANSI sequence in WS messages` },
|
|
416
|
+
)
|
|
417
|
+
.toBe(true)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
})
|