@volter/twin-github 0.1.0 → 0.1.1

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.
@@ -12,8 +12,11 @@
12
12
  // issues(first) { totalCount nodes { number title state } } } }
13
13
  // query { node(id) { ... on PullRequest { number title } } } — id = "PR_<owner>/<repo>#<n>"
14
14
  // mutation { addComment(input: { subjectId, body }) { commentEdge { node { id body } } } }
15
+ // query { repository(owner, name) { discussions(first) { nodes { id number title body category { name slug isAnswerable }
16
+ // comments { nodes { id body isAnswer } } } } } } — id = "D_<owner>/<repo>#<n>"
17
+ // mutation { addDiscussionComment(input: { discussionId, body, replyToId? }) { comment { id body url } } }
15
18
  // Anything else (other root fields, unmodeled selection fields) → a typed GraphQL error.
16
- import { applyGithubWrite } from './github-twin.ts';
19
+ import { applyGithubWrite, prPlaneDiffFiles } from './github-twin.ts';
17
20
  import { githubState, type GithubState } from './github-twin.ts';
18
21
 
19
22
  export type GraphqlResult = { data?: Record<string, unknown> | null; errors?: Array<{ message: string; type?: string }> };
@@ -33,6 +36,29 @@ const REPO_FIELDS = new Set([
33
36
  // RepositoryInfo (gh pr create / gh repo view): the `repo` fragment + repo-level merge settings.
34
37
  'id', 'owner', 'login', 'hasIssuesEnabled', 'description', 'hasWikiEnabled', 'viewerPermission',
35
38
  'defaultBranchRef', 'parent', 'mergeCommitAllowed', 'rebaseMergeAllowed', 'squashMergeAllowed', 'repo',
39
+ // gh pr view <n> / gh issue view <n> resolve ONE node by number.
40
+ 'pullRequest', 'issue',
41
+ // Discussions: real GitHub serves them over GraphQL alone (repository.discussions; addDiscussionComment).
42
+ 'discussions',
43
+ ]);
44
+ const DISCUSSION_FIELDS = new Set(['id', 'number', 'title', 'body', 'url', 'createdAt', 'updatedAt', 'category', 'name', 'slug', 'isAnswerable', 'comments', 'author', 'login', 'isAnswered', 'closed', 'locked', '__typename']);
45
+ // The single-PR fields `gh pr view --json …` selects — the exact set the repo's own
46
+ // merge-gate scripts read (headRefOid/state/baseRefName/statusCheckRollup/
47
+ // closingIssuesReferences/changedFiles/labels/body/comments/assignees/reviewRequests; see
48
+ // scripts/human-approval-gate.ts, break-glass-gate.ts, finalize-agent-review.ts,
49
+ // review-prerequisites.ts). `statusCheckRollup` is gh's ALIAS for commits(last:1){…} — the
50
+ // token list admits both spellings. Anything else stays an honest undefinedField error.
51
+ const PR_VIEW_FIELDS = new Set([
52
+ 'number', 'id', 'url', 'title', 'state', 'body', 'isDraft', 'headRefOid', 'headRefName',
53
+ 'baseRefName', 'changedFiles', 'additions', 'deletions', 'createdAt', 'updatedAt', 'closedAt',
54
+ 'mergedAt', 'labels', 'closingIssuesReferences', 'statusCheckRollup', 'commits', 'comments',
55
+ 'assignees', 'reviewRequests', '__typename',
56
+ ]);
57
+ // The single-issue fields `gh issue view --json …` selects (human-approval-gate reads a
58
+ // linked issue's labels; finalize-agent-review reads its comments and posts one).
59
+ const ISSUE_VIEW_FIELDS = new Set([
60
+ 'number', 'id', 'url', 'title', 'state', 'body', 'labels', 'comments', 'assignees',
61
+ 'createdAt', 'updatedAt', 'closedAt', 'author', '__typename',
36
62
  ]);
37
63
  const PR_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename',
38
64
  // PR connection node fields gh pr view/list select (modeled from the PR projection).
@@ -72,6 +98,8 @@ function selectionFields(query: string, parentToken: string): string[] {
72
98
  function argValue(query: string, argName: string, variables: Record<string, unknown>): string | undefined {
73
99
  const lit = new RegExp(`${argName}:\\s*"([^"]*)"`).exec(query);
74
100
  if (lit) return lit[1];
101
+ const num = new RegExp(`${argName}:\\s*(-?\\d+)`).exec(query); // Int literal (pullRequest(number: 2))
102
+ if (num) return num[1];
75
103
  const ref = new RegExp(`${argName}:\\s*\\$([A-Za-z_][A-Za-z0-9_]*)`).exec(query);
76
104
  if (ref && variables[ref[1]!] !== undefined) return String(variables[ref[1]!]);
77
105
  return undefined;
@@ -98,6 +126,21 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
98
126
 
99
127
  // ── mutation addComment(input: { subjectId, body }) ──────────────────────────────────────
100
128
  if (isMutation) {
129
+ // ── mutation addDiscussionComment(input: { discussionId, body, replyToId? }) ──────────────
130
+ // discussionId "D_<repo>#<n>" (what repository.discussions returns); maps onto the SAME write path the
131
+ // REST twin uses for a discussion comment.
132
+ if (/\baddDiscussionComment\b/.test(query)) {
133
+ const discussionId = String(vars.discussionId ?? argValue(query, 'discussionId', vars) ?? '');
134
+ const body = String(vars.body ?? argValue(query, 'body', vars) ?? '');
135
+ const m = /^D_(.+)#(\d+)$/.exec(discussionId);
136
+ if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${discussionId}'`, type: 'NOT_FOUND' }] };
137
+ const repo = m[1]!; const number = Number(m[2]);
138
+ const replyTo = /^DC_(.+)#(\d+)$/.exec(String(vars.replyToId ?? argValue(query, 'replyToId', vars) ?? ''));
139
+ const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/discussions/${number}/comments`, body: JSON.stringify({ body, ...(replyTo ? { parent_id: Number(replyTo[2]) } : {}) }), root: req.root });
140
+ if (res.response.status !== 201) return { errors: [{ message: 'addDiscussionComment failed', type: res.response.status === 404 ? 'NOT_FOUND' : 'UNPROCESSABLE' }] };
141
+ const cid = (res.response.body as { id?: number }).id;
142
+ return { data: { addDiscussionComment: { comment: { id: `DC_${repo}#${cid}`, body, url: `https://github.com/${repo}/discussions/${number}#discussioncomment-${cid}` } } } };
143
+ }
101
144
  if (/\baddComment\b/.test(query)) {
102
145
  // subjectId/body come from variables or inline input. subjectId "PR_<repo>#<n>" or "I_…".
103
146
  const subjectId = String(vars.subjectId ?? argValue(query, 'subjectId', vars) ?? '');
@@ -154,6 +197,68 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
154
197
  if (bad) return { errors: [unmodeled(bad, 'Repository')] };
155
198
  const known = state.repos.find((r) => r.full_name === repo);
156
199
  const { prs, issues } = repoToNodes(state, repo);
200
+ // ── repository.pullRequest(number:) — the single-PR read gh pr view sends ──────────────
201
+ // (`\bpullRequest\b(?!s)` so the plural connection never shadows it.)
202
+ // Both single-node branches anchor on the FIELD-WITH-ARGS form (`pullRequest(` /
203
+ // `issue(`): a bare word match would hijack queries whose owner/name ARGUMENT contains
204
+ // the token (repository(name: "issue-tracker") — a real §9 round-two find).
205
+ const prTokenAt = query.search(/\bpullRequest\b(?!s)\s*\(/);
206
+ if (prTokenAt >= 0) {
207
+ const sub = query.slice(prTokenAt);
208
+ const bad = selectionFields(sub, 'pullRequest').find((f) => !PR_VIEW_FIELDS.has(f));
209
+ if (bad) return { errors: [unmodeled(bad, 'PullRequest')] };
210
+ const num = Number(vars.number ?? vars.pr_number ?? vars.prNumber ?? argValue(sub, 'number', vars) ?? NaN);
211
+ const pr = state.prs.find((p) => p.repository === repo && p.number === num);
212
+ if (!pr) return { errors: [{ message: `Could not resolve to a PullRequest with the number of ${num}.`, type: 'NOT_FOUND' }] };
213
+ return { data: { repository: { pullRequest: resolvePullRequestView(state, repo, pr, req.root) } } };
214
+ }
215
+ // ── repository.issue(number:) — gh issue view's single-issue read ──────────────────────
216
+ const issueTokenAt = query.search(/\bissue\b(?!s)\s*\(/);
217
+ if (issueTokenAt >= 0) {
218
+ const sub = query.slice(issueTokenAt);
219
+ const bad = selectionFields(sub, 'issue').find((f) => !ISSUE_VIEW_FIELDS.has(f));
220
+ if (bad) return { errors: [unmodeled(bad, 'Issue')] };
221
+ const num = Number(vars.number ?? argValue(sub, 'number', vars) ?? NaN);
222
+ const issue = state.issues.find((i) => i.repository === repo && i.number === num);
223
+ if (!issue) return { errors: [{ message: `Could not resolve to an Issue with the number of ${num}.`, type: 'NOT_FOUND' }] };
224
+ const labels = (issue.labels ?? []).map((name) => ({ name }));
225
+ const comments = state.comments
226
+ .filter((c) => c.repository === repo && c.number === num && c.kind === 'issue')
227
+ .map((c) => ({
228
+ id: `IC_${repo}#${c.id}`, author: null, authorAssociation: 'NONE', body: c.body ?? '',
229
+ createdAt: c.created_at ?? null, includesCreatedEdit: false, isMinimized: false, minimizedReason: null,
230
+ reactionGroups: [], url: `https://github.com/${repo}/issues/${num}#issuecomment-${c.id}`, viewerDidAuthor: false,
231
+ }));
232
+ return { data: { repository: { issue: {
233
+ number: num, id: `I_${repo}#${num}`, url: `https://github.com/${repo}/issues/${num}`,
234
+ title: issue.title ?? null, state: (issue.state ?? 'open').toUpperCase(), body: issue.body ?? '',
235
+ author: null, createdAt: null, updatedAt: null, closedAt: null,
236
+ labels: { nodes: labels, totalCount: labels.length },
237
+ comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
238
+ assignees: { nodes: (issue.assignees ?? []).map((login) => ({ login, id: `U_${login}`, name: null })), totalCount: (issue.assignees ?? []).length },
239
+ } } } };
240
+ }
241
+ // ── repository.discussions(first, orderBy) — the community's threads, with their comments ─────
242
+ if (/\bdiscussions\b/.test(query)) {
243
+ const bad = selectionFields(query, 'discussions').find((f) => !DISCUSSION_FIELDS.has(f) && !CONNECTION_FIELDS.has(f));
244
+ if (bad) return { errors: [unmodeled(bad, 'Discussion')] };
245
+ const categories = state.discussionCategories.filter((c) => c.repository === repo);
246
+ const nodes = state.discussions.filter((d) => d.repository === repo).map((d) => {
247
+ const cat = categories.find((c) => c.slug === d.category_slug);
248
+ const comments = state.discussionComments.filter((c) => c.repository === repo && c.discussion_number === d.number).map((c) => ({
249
+ id: `DC_${repo}#${c.id}`, body: c.body ?? '', createdAt: c.created_at ?? null, author: null, isAnswer: Boolean(c.is_answer),
250
+ replyTo: c.parent_id ? { id: `DC_${repo}#${c.parent_id}` } : null,
251
+ }));
252
+ return {
253
+ id: `D_${repo}#${d.number}`, number: d.number, title: d.title ?? null, body: d.body ?? '', url: `https://github.com/${repo}/discussions/${d.number}`,
254
+ createdAt: d.created_at ?? null, updatedAt: d.updated_at ?? null, author: null, closed: d.state === 'closed', locked: Boolean(d.locked),
255
+ isAnswered: d.answer_comment_id !== undefined && d.answer_comment_id !== null,
256
+ category: cat ? { id: `DIC_${repo}#${cat.id}`, name: cat.name, slug: cat.slug, isAnswerable: Boolean(cat.is_answerable) } : null,
257
+ comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
258
+ };
259
+ });
260
+ return { data: { repository: { id: `R_${repo}`, nameWithOwner: repo, discussions: { nodes, totalCount: nodes.length, pageInfo: { hasNextPage: false, endCursor: null } } } } };
261
+ }
157
262
  // unmodeled subfields under pullRequests/issues nodes are honest errors too (connection-level
158
263
  // fields like pageInfo/edges are allowed; only unknown PullRequest/Issue node fields error).
159
264
  if (/\bpullRequests\b/.test(query)) { const b = selectionFields(query, 'pullRequests').find((f) => !PR_FIELDS.has(f) && !CONNECTION_FIELDS.has(f)); if (b) return { errors: [unmodeled(b, 'PullRequest')] }; }
@@ -185,3 +290,109 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
185
290
  const unknownRoot = rootFields.find((f) => !['viewer', 'repository', 'node'].includes(f));
186
291
  return { errors: [unmodeled(unknownRoot ?? rootFields[0] ?? 'unknown', 'Query')] };
187
292
  }
293
+
294
+ // ── The single-PR view model (repository.pullRequest) ──────────────────────────────────────
295
+ // Every field folds from the SAME projection the REST twin serves — headRefOid is the PR's
296
+ // resolved head sha, statusCheckRollup folds the SAME check-run/commit-status rows the REST
297
+ // checks surface reads, closingIssuesReferences derives from the PR body's closing keywords
298
+ // (the same linkage real GitHub computes). Nothing here is a second store.
299
+
300
+ // GitHub GraphQL enum spellings for the rollup (StatusState / CheckStatusState / CheckConclusionState).
301
+ function statusStateEnum(s: string | undefined): string {
302
+ const v = (s ?? 'pending').toUpperCase();
303
+ return ['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS'].includes(v) ? v : 'PENDING';
304
+ }
305
+ function checkStatusEnum(s: string | undefined): string {
306
+ const v = (s ?? 'queued').toUpperCase();
307
+ return ['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED'].includes(v) ? v : 'QUEUED';
308
+ }
309
+ function checkConclusionEnum(s: string | undefined): string | null {
310
+ if (!s) return null;
311
+ const v = s.toUpperCase();
312
+ return ['ACTION_REQUIRED', 'TIMED_OUT', 'CANCELLED', 'FAILURE', 'SUCCESS', 'NEUTRAL', 'SKIPPED', 'STARTUP_FAILURE', 'STALE'].includes(v) ? v : null;
313
+ }
314
+
315
+ /** The closing-keyword issue linkage (Closes/Fixes/Resolves #n) real GitHub derives from the PR body. */
316
+ export function closingIssueNumbersFromBody(body: string | undefined): number[] {
317
+ const out: number[] = [];
318
+ for (const m of (body ?? '').matchAll(/\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing))\s*:?\s+#(\d+)/gi)) {
319
+ const n = Number(m[1]);
320
+ if (!out.includes(n)) out.push(n);
321
+ }
322
+ return out;
323
+ }
324
+
325
+ type GhPrRow = GithubState['prs'][number];
326
+
327
+ function resolvePullRequestView(state: GithubState, repo: string, pr: GhPrRow, root?: string): Record<string, unknown> {
328
+ const headSha = pr.head_sha ?? '';
329
+ // Latest commit-status per context (GitHub's rollup shows each context once, most recent wins).
330
+ const latestByContext = new Map<string, GithubState['statuses'][number]>();
331
+ for (const s of state.statuses.filter((s) => s.repository === repo && s.sha === headSha).sort((a, b) => a.id - b.id)) {
332
+ latestByContext.set(s.context ?? 'default', s);
333
+ }
334
+ const runs = state.checkRuns.filter((c) => c.repository === repo && c.head_sha === headSha).sort((a, b) => a.id - b.id);
335
+ const contexts: Array<Record<string, unknown>> = [
336
+ ...runs.map((c) => ({
337
+ __typename: 'CheckRun', name: c.name,
338
+ status: checkStatusEnum(c.status), conclusion: checkConclusionEnum(c.conclusion),
339
+ startedAt: c.started_at ?? null, completedAt: c.completed_at ?? null, detailsUrl: c.details_url ?? null,
340
+ })),
341
+ ...[...latestByContext.values()].map((s) => ({
342
+ __typename: 'StatusContext', context: s.context, state: statusStateEnum(s.state),
343
+ targetUrl: s.target_url ?? null, description: s.description ?? null, createdAt: s.created_at ?? null,
344
+ })),
345
+ ];
346
+ const anyFailed = contexts.some((c) =>
347
+ (c.__typename === 'CheckRun' && ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion)))
348
+ || (c.__typename === 'StatusContext' && ['FAILURE', 'ERROR'].includes(String(c.state))));
349
+ const anyPending = contexts.some((c) =>
350
+ (c.__typename === 'CheckRun' && c.conclusion === null)
351
+ || (c.__typename === 'StatusContext' && ['PENDING', 'EXPECTED'].includes(String(c.state))));
352
+ const rollupState = contexts.length === 0 ? null : anyFailed ? 'FAILURE' : anyPending ? 'PENDING' : 'SUCCESS';
353
+ const rollup = {
354
+ nodes: [{
355
+ commit: {
356
+ statusCheckRollup: rollupState === null ? null : {
357
+ state: rollupState,
358
+ contexts: { nodes: contexts, totalCount: contexts.length, pageInfo: { hasNextPage: false, endCursor: null } },
359
+ },
360
+ },
361
+ }],
362
+ };
363
+ const comments = state.comments
364
+ .filter((c) => c.repository === repo && c.number === pr.number && c.kind === 'issue')
365
+ .map((c) => ({
366
+ id: `IC_${repo}#${c.id}`, author: null, authorAssociation: 'NONE',
367
+ body: c.body ?? '', createdAt: c.created_at ?? null, includesCreatedEdit: false, isMinimized: false,
368
+ minimizedReason: null, reactionGroups: [], url: `https://github.com/${repo}/pull/${pr.number}#issuecomment-${c.id}`,
369
+ viewerDidAuthor: false,
370
+ }));
371
+ const closing = closingIssueNumbersFromBody(pr.body)
372
+ .filter((n) => state.issues.some((i) => i.repository === repo && i.number === n))
373
+ .map((n) => {
374
+ const issue = state.issues.find((i) => i.repository === repo && i.number === n)!;
375
+ return { number: n, title: issue.title ?? null, url: `https://github.com/${repo}/issues/${n}` };
376
+ });
377
+ const stateEnum = pr.merged ? 'MERGED' : (pr.state ?? 'open').toUpperCase();
378
+ // changedFiles: carried count → carried per-file list → the REAL diff from the git plane
379
+ // (a pushed PR's true file count — merge-gate scope classification depends on this being
380
+ // the actual paths, not 0) → 0 only when no evidence exists anywhere.
381
+ const planeFiles = pr.changed_files === undefined && !pr.files?.length ? prPlaneDiffFiles(state, repo, pr, root) : null;
382
+ return {
383
+ number: pr.number, id: `PR_${repo}#${pr.number}`, url: `https://github.com/${repo}/pull/${pr.number}`,
384
+ title: pr.title ?? null, state: stateEnum, body: pr.body ?? '', isDraft: pr.draft ?? false,
385
+ headRefOid: headSha, headRefName: pr.head_ref ?? null, baseRefName: pr.base_ref ?? null,
386
+ changedFiles: pr.changed_files ?? pr.files?.length ?? planeFiles?.length ?? 0,
387
+ additions: pr.additions ?? 0, deletions: pr.deletions ?? 0,
388
+ createdAt: null, updatedAt: null, closedAt: null, mergedAt: pr.merged_at ?? null,
389
+ labels: { nodes: (pr.labels ?? []).map((name) => ({ name })), totalCount: (pr.labels ?? []).length },
390
+ closingIssuesReferences: { nodes: closing, totalCount: closing.length, pageInfo: { hasNextPage: false, endCursor: null } },
391
+ // gh's `statusCheckRollup` json field is the alias for commits(last:1){…} — serve BOTH
392
+ // spellings so the alias and the raw field name each resolve.
393
+ statusCheckRollup: rollup, commits: rollup,
394
+ comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
395
+ assignees: { nodes: (pr.assignees ?? []).map((login) => ({ login, id: `U_${login}`, name: null })), totalCount: (pr.assignees ?? []).length },
396
+ reviewRequests: { nodes: (pr.requested_reviewers ?? []).map((login) => ({ requestedReviewer: { __typename: 'User', login } })), totalCount: (pr.requested_reviewers ?? []).length },
397
+ };
398
+ }
@@ -0,0 +1,193 @@
1
+ // GITHUB PILOT UI JOURNEY (TWIN-47 / H1, dev/02) — the first real proof of the uiJourney
2
+ // harness: seed PRs/issues/reviews through the pack's OWN write path (`applyGithubWrite`,
3
+ // same handler github-writes.test.ts / github-ui-structure.ts already use — never
4
+ // hand-written events.jsonl), boot the real mirror server (`createGithubMirrorServer`) on an
5
+ // ephemeral port, and drive it with real headless Playwright chromium using role/text
6
+ // locators ONLY — the locators an agent transfers from the real GitHub UI, never a
7
+ // className/test-id a test author invented. Proves navigation + LIVE interactivity: tab
8
+ // clicks actually swap the rendered view, and selecting a PR actually flows PR-specific data
9
+ // (body/review content) that the list row never shows.
10
+ //
11
+ // NOTE on assertions: we import the bare `playwright` library (not `@playwright/test`), so
12
+ // there is no web-first `expect(locator).toBeVisible()`. Instead we use Playwright's own
13
+ // auto-waiting/polling primitive directly — `locator.waitFor({ state })` — which retries until
14
+ // the DOM state holds or the page's default timeout elapses, then throws (failing the
15
+ // journey) if it never does. `visible`/`attached` prove seeded data reached the live DOM;
16
+ // `detached` proves a view actually unmounted (not merely CSS-hidden) after a tab switch.
17
+ //
18
+ // Must never hard-fail on a browserless machine: skip cleanly (loud console line) when
19
+ // `browserAvailable()` is false. The GATE-level advisory (never-silently-green) is the job of
20
+ // scripts/ui-journeys.ts; this file's own skip is a courtesy for anyone running `bun test`
21
+ // directly on a machine with no chromium cache.
22
+ //
23
+ // TWIN-49 (H3, dev/02) additions: the pilot journey now ALSO asserts the vendor-faithful
24
+ // pathname at each navigation step (client-side pushState routing — see pathFor/stateFor in
25
+ // github-mirror.tsx), an issue-detail case flips `github.journey.issue_detail` from a census
26
+ // todo to a real spec (the Issues row is now a role-reachable <button>), and a NEW deep-link
27
+ // journey `goto()`s a PR path directly (no clicking) and proves back/forward (popstate).
28
+ import { describe, test } from 'bun:test';
29
+ import type { Page } from 'playwright';
30
+ import { requireBrowser, runUiJourney, JOURNEY_TIMEOUT_MS, visible, gone, atPath, waitForMount } from '@volter/twin-tooling';
31
+
32
+ const JOURNEY_LABEL = 'github UI journey';
33
+ import { applyGithubWrite } from './github-twin.ts';
34
+ import { createGithubMirrorServer } from './github-mirror-ui.ts';
35
+
36
+ // `.first()`: some seeded titles legitimately render TWICE at once (the currently-selected PR's
37
+ // list row AND its Conversation <h2> both carry the title) — that duplication is real product
38
+ // behavior, not a bug, so we assert against "at least/at most this one instance" rather than
39
+ // forcing every locator to be uniquely-matching.
40
+ /** Wait until `text` renders visibly on the live page (throws/fails the journey if it never does). */
41
+ /** Wait until NO element matching `text` remains in the DOM (proves a real unmount, not CSS hiding). */
42
+ /** Wait until the live page's pathname matches `path` exactly (proves pushState/popstate — not
43
+ * the address-bar decoration text, the REAL `location.pathname` the browser tracks). */
44
+ /** Wait until a DOM mount has actually happened after a fresh `page.goto` (the harness's own
45
+ * mount-wait only covers the FIRST navigation; a deep-link journey re-navigates mid-test). */
46
+
47
+ describe('github UI journey (pilot, TWIN-47 dev/02)', () => {
48
+ test('seeded PRs/issues/reviews are reachable by real browser navigation', async () => {
49
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
50
+
51
+ await runUiJourney({
52
+ label: 'github seeded PRs/issues/reviews are reachable by real browser navigat',
53
+ seed: async (root) => {
54
+ // Real write path — same handler github-writes.test.ts / github-ui-structure.ts use.
55
+ // PR #1 (earlier): a distinct, greppable title + body only the detail pane shows.
56
+ await applyGithubWrite({
57
+ method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
58
+ body: JSON.stringify({ title: 'Twin Journey Alpha PR', head: 'alpha', base: 'main', body: 'alpha-pr-body-marker' }),
59
+ occurredAt: '2026-05-01T00:00:00Z',
60
+ });
61
+ // PR #2 (later, so it sorts first by updatedAt): distinct title/body + a review whose
62
+ // body is ONLY reachable once this PR is selected (never shown in the list row).
63
+ await applyGithubWrite({
64
+ method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
65
+ body: JSON.stringify({ title: 'Twin Journey Beta PR', head: 'beta', base: 'main', body: 'beta-pr-body-marker' }),
66
+ occurredAt: '2026-05-02T00:00:00Z',
67
+ });
68
+ await applyGithubWrite({
69
+ method: 'POST', path: '/repos/acme/twin-journey/pulls/2/reviews', root,
70
+ body: JSON.stringify({ event: 'APPROVED', body: 'beta-review-body-marker' }),
71
+ occurredAt: '2026-05-02T01:00:00Z',
72
+ });
73
+ // One issue with a distinct title + body (the body is ONLY reachable once the issue is
74
+ // opened — the Issues list row never renders it) — proves both the Issues tab swap AND
75
+ // the issue-detail selection (dev/02: github.journey.issue_detail).
76
+ await applyGithubWrite({
77
+ method: 'POST', path: '/repos/acme/twin-journey/issues', root,
78
+ body: JSON.stringify({ title: 'Twin Journey Tracking Issue', body: 'tracking-issue-body-marker' }),
79
+ occurredAt: '2026-05-03T00:00:00Z',
80
+ });
81
+ },
82
+ serve: (root) => {
83
+ const server = createGithubMirrorServer({ root, port: 0 });
84
+ return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
85
+ },
86
+ journey: async (page) => {
87
+ // 1. Navigation lands on the PR list; both seeded PR titles render (list view shows
88
+ // ALL matching PRs, so both are visible before any selection). URL: the "all repos"
89
+ // cross-repo pulls page (no PR opened yet) — /pulls.
90
+ await visible(page, 'Twin Journey Alpha PR');
91
+ await visible(page, 'Twin Journey Beta PR');
92
+ await atPath(page, '/pulls');
93
+
94
+ // 2. Click the Issues TAB (a real <button>, role-reachable) → the seeded issue title
95
+ // appears AND the pulls-only markers (the seeded PR titles) are gone — proves the
96
+ // click handler fired and the view actually swapped (hydration is live, not SSR
97
+ // residue left over from the initial HTML). URL: /issues (no issue opened yet).
98
+ await page.getByRole('button', { name: /^Issues/ }).click();
99
+ await visible(page, 'Twin Journey Tracking Issue');
100
+ await gone(page, 'Twin Journey Alpha PR');
101
+ await gone(page, 'Twin Journey Beta PR');
102
+ await atPath(page, '/issues');
103
+
104
+ // 2b. Click the seeded issue's row (dev/02: github.journey.issue_detail — the Issues
105
+ // row is a role-reachable <button>, not a bare onClick <div>). With only one seeded
106
+ // issue, its detail pane is already shown as the split-pane default (same
107
+ // single-item fallback the PR view uses, so no "gone before click" assertion here
108
+ // — that's covered by the multi-PR case below); the URL is what proves the click
109
+ // actually navigated: it becomes the issue's own repo-scoped detail path — issues
110
+ // and PRs share one number counter, so this repo's 3rd object (after the 2 seeded
111
+ // PRs) is issue #3.
112
+ await visible(page, 'tracking-issue-body-marker');
113
+ await page.getByRole('button', { name: /Twin Journey Tracking Issue/ }).click();
114
+ await atPath(page, '/acme/twin-journey/issues/3');
115
+
116
+ // 3. Click back to Pull requests — the PR titles reappear (round-trip proves the tab
117
+ // state, not a one-way navigation, actually drives the render). URL: back to /pulls
118
+ // (no PR opened in this run yet, so no numbered path).
119
+ await page.getByRole('button', { name: /^Pull requests/ }).click();
120
+ await visible(page, 'Twin Journey Alpha PR');
121
+ await atPath(page, '/pulls');
122
+
123
+ // 4. Click the Alpha PR's row (a real <button>, role-reachable via its title text) →
124
+ // its body appears in the detail pane — content the list row NEVER renders (the
125
+ // list row only shows title/repo/state/meta, never pr.body). This proves selection
126
+ // state flows PR-specific data through a live store-door fetch (`GET /twin/store/mirror`), not a static shell.
127
+ // URL: opening a PR always routes to ITS OWN repo's numbered path, even from the
128
+ // "all repos" list — /acme/twin-journey/pull/1.
129
+ await gone(page, 'alpha-pr-body-marker'); // not shown pre-selection (Beta sorts first)
130
+ await page.getByRole('button', { name: /Twin Journey Alpha PR/ }).click();
131
+ await visible(page, 'alpha-pr-body-marker');
132
+ await atPath(page, '/acme/twin-journey/pull/1');
133
+
134
+ // 5. Click the Beta PR's row → its review body (seeded via a REAL review write)
135
+ // becomes visible — proves the review data-coupling reaches the live DOM too, and
136
+ // that switching selection re-renders (Alpha's body is no longer shown). URL moves
137
+ // to PR #2's path.
138
+ await page.getByRole('button', { name: /Twin Journey Beta PR/ }).click();
139
+ await visible(page, 'beta-review-body-marker');
140
+ await gone(page, 'alpha-pr-body-marker');
141
+ await atPath(page, '/acme/twin-journey/pull/2');
142
+ },
143
+ });
144
+ }, JOURNEY_TIMEOUT_MS);
145
+ });
146
+
147
+ describe('github UI journey — deep link + back/forward (TWIN-49 / H3, dev/02)', () => {
148
+ test('a direct deep link to a PR renders it without clicking, and back restores it (popstate)', async () => {
149
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
150
+
151
+ await runUiJourney({
152
+ label: 'github a direct deep link to a PR renders it without clicking, and bac',
153
+ seed: async (root) => {
154
+ await applyGithubWrite({
155
+ method: 'POST', path: '/repos/acme/deep-link-journey/pulls', root,
156
+ body: JSON.stringify({ title: 'Deep Link Alpha PR', head: 'alpha', base: 'main', body: 'deep-link-alpha-body-marker' }),
157
+ occurredAt: '2026-05-10T00:00:00Z',
158
+ });
159
+ await applyGithubWrite({
160
+ method: 'POST', path: '/repos/acme/deep-link-journey/pulls', root,
161
+ body: JSON.stringify({ title: 'Deep Link Beta PR', head: 'beta', base: 'main', body: 'deep-link-beta-body-marker' }),
162
+ occurredAt: '2026-05-10T01:00:00Z',
163
+ });
164
+ },
165
+ serve: (root) => {
166
+ const server = createGithubMirrorServer({ root, port: 0 });
167
+ return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
168
+ },
169
+ journey: async (page) => {
170
+ // Deep link: goto the PR #2 path DIRECTLY (no clicking at all) — the client must
171
+ // derive the initial view + selection from `location.pathname` on mount, not from any
172
+ // in-memory click history.
173
+ const origin = new URL(page.url()).origin;
174
+ await page.goto(`${origin}/acme/deep-link-journey/pull/2`);
175
+ await waitForMount(page);
176
+ await visible(page, 'deep-link-beta-body-marker');
177
+ await gone(page, 'deep-link-alpha-body-marker'); // proves PR #2 rendered, not a fallback/default
178
+ await atPath(page, '/acme/deep-link-journey/pull/2');
179
+
180
+ // Click to another view (real navigation, pushState) — proves the deep-linked page is
181
+ // still live/interactive, not a dead static render.
182
+ await page.getByRole('button', { name: /^Issues/ }).click();
183
+ await atPath(page, '/acme/deep-link-journey/issues');
184
+ await gone(page, 'deep-link-beta-body-marker');
185
+
186
+ // Back (popstate tooth): the PR view AND its URL are both restored.
187
+ await page.goBack();
188
+ await atPath(page, '/acme/deep-link-journey/pull/2');
189
+ await visible(page, 'deep-link-beta-body-marker');
190
+ },
191
+ });
192
+ }, JOURNEY_TIMEOUT_MS);
193
+ });