github-issue-tower-defence-management 1.95.0 → 1.97.0

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 (69) hide show
  1. package/.github/workflows/console-ui.yml +17 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +20 -0
  4. package/bin/adapter/entry-points/cli/index.js +21 -0
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +18 -2
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  10. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
  11. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
  12. package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js +19 -0
  13. package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js.map +1 -0
  14. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +44 -0
  15. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -0
  16. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
  17. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
  18. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
  19. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
  20. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
  21. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
  22. package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js +3 -0
  23. package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js.map +1 -0
  24. package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js +41 -0
  25. package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js.map +1 -0
  26. package/package.json +4 -1
  27. package/src/adapter/entry-points/cli/index.ts +38 -0
  28. package/src/adapter/entry-points/console/ui/biome.json +7 -1
  29. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
  30. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
  31. package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
  32. package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +2 -4
  34. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -6
  35. package/src/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
  36. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  37. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -0
  38. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
  39. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
  40. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +123 -0
  41. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.ts +29 -0
  42. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +100 -0
  43. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +53 -0
  44. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
  45. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
  46. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
  47. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
  48. package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
  49. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +9 -0
  50. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +186 -0
  51. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.ts +75 -0
  52. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  53. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  54. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
  55. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
  56. package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts +10 -0
  57. package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts.map +1 -0
  58. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +10 -0
  59. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -0
  60. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
  61. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
  62. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
  63. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
  64. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
  65. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -0
  66. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +6 -0
  67. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -0
  68. package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts +19 -0
  69. package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts.map +1 -0
@@ -42,6 +42,7 @@ import {
42
42
  createConsoleProjectResolver,
43
43
  } from '../console/consoleProjectResolver';
44
44
  import { OauthTokenSelectHandler } from '../handlers/OauthTokenSelectHandler';
45
+ import { LiveSessionOauthTokenSelectHandler } from '../handlers/LiveSessionOauthTokenSelectHandler';
45
46
 
46
47
  type StartDaemonOptions = {
47
48
  projectUrl?: string;
@@ -89,6 +90,11 @@ type SelectOauthTokenOptions = {
89
90
  cacheDir?: string;
90
91
  };
91
92
 
93
+ type SelectLiveSessionOauthTokenOptions = {
94
+ tokenListJsonPath?: string;
95
+ cacheDir?: string;
96
+ };
97
+
92
98
  const buildGithubRepositoryParams = (
93
99
  localStorageRepository: LocalStorageRepository,
94
100
  token: string,
@@ -757,6 +763,38 @@ program
757
763
  process.stdout.write(`${output.selectedToken}\n`);
758
764
  });
759
765
 
766
+ program
767
+ .command('selectLiveSessionOauthToken')
768
+ .description(
769
+ 'Print exactly one Claude Code OAuth token chosen for a new live interactive session. Among rate-limit-eligible tokens it prefers the one with the fewest current live sessions (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes), tiebreaking on the soonest 7d reset. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
770
+ )
771
+ .option(
772
+ '--tokenListJsonPath <path>',
773
+ 'Path to the JSON array of { name, token } records. Falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable.',
774
+ )
775
+ .option(
776
+ '--cacheDir <path>',
777
+ 'Directory holding per-token rate-limit cache files. Falls back to the TDPM_RATELIMIT_CACHE_DIR environment variable, then to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit.',
778
+ )
779
+ .action((options: SelectLiveSessionOauthTokenOptions) => {
780
+ const handler = new LiveSessionOauthTokenSelectHandler();
781
+ const output = handler.handle({
782
+ tokenListJsonPath: options.tokenListJsonPath ?? null,
783
+ cacheDirectory: options.cacheDir ?? null,
784
+ nowEpochSeconds: Date.now() / 1000,
785
+ });
786
+
787
+ for (const line of output.diagnostics) {
788
+ console.error(line);
789
+ }
790
+
791
+ if (output.selectedToken === null) {
792
+ process.exit(1);
793
+ }
794
+
795
+ process.stdout.write(`${output.selectedToken}\n`);
796
+ });
797
+
760
798
  /* istanbul ignore next */
761
799
  if (process.argv && require.main === module) {
762
800
  program.parse(process.argv);
@@ -8,7 +8,13 @@
8
8
  },
9
9
  "files": {
10
10
  "ignoreUnknown": false,
11
- "includes": ["src/**", ".storybook/**"]
11
+ "includes": [
12
+ "src/**",
13
+ ".storybook/**",
14
+ "e2e/**",
15
+ "!e2e/report/**",
16
+ "!e2e/test-results/**"
17
+ ]
12
18
  },
13
19
  "formatter": {
14
20
  "enabled": true,
@@ -0,0 +1,93 @@
1
+ import { expect, type Page, test } from '@playwright/test';
2
+ import {
3
+ type ConsoleE2eHarness,
4
+ startConsoleE2eHarness,
5
+ } from './consoleTestHarness';
6
+
7
+ let harness: ConsoleE2eHarness;
8
+
9
+ test.beforeAll(async () => {
10
+ harness = await startConsoleE2eHarness();
11
+ });
12
+
13
+ test.afterAll(async () => {
14
+ if (harness !== undefined) {
15
+ await harness.stop();
16
+ }
17
+ });
18
+
19
+ const activeTabLabel = (page: Page) =>
20
+ page.locator('.console-tab[data-active="true"] .console-tab-label');
21
+
22
+ const tabByLabel = (page: Page, label: string) =>
23
+ page.locator('.console-tab', { hasText: label });
24
+
25
+ const tabBadge = (page: Page, label: string) =>
26
+ tabByLabel(page, label).locator('.console-tab-badge');
27
+
28
+ const itemRowByText = (page: Page, text: string) =>
29
+ page.locator('.console-item-row', { hasText: text });
30
+
31
+ const processSelectedItemViaStatus = async (page: Page): Promise<void> => {
32
+ const statusButton = page
33
+ .locator('.console-op-button', { hasText: 'Awaiting Workspace' })
34
+ .first();
35
+ await expect(statusButton).toBeVisible();
36
+ await statusButton.click();
37
+ };
38
+
39
+ test('processing tabs drives auto-advance and keeps emptied badges at zero', async ({
40
+ page,
41
+ }) => {
42
+ await page.goto(harness.appUrl);
43
+
44
+ await expect(activeTabLabel(page)).toHaveText('Awaiting Quality Check');
45
+ await expect(tabBadge(page, 'Awaiting Quality Check')).toHaveText('1');
46
+ await expect(tabBadge(page, 'Unread')).toHaveText('2');
47
+ await expect(tabBadge(page, 'Triage')).toHaveText('2');
48
+ await expect(tabBadge(page, 'Todo by human')).toHaveText('1');
49
+
50
+ await itemRowByText(
51
+ page,
52
+ 'Serve the committed console UI bundle from serveConsole',
53
+ ).click();
54
+ const approveButton = page
55
+ .locator('.console-op-button', { hasText: 'Approve' })
56
+ .first();
57
+ await expect(approveButton).toBeVisible();
58
+ await approveButton.click();
59
+
60
+ await expect(activeTabLabel(page)).toHaveText('Triage', { timeout: 8000 });
61
+ await expect(tabByLabel(page, 'Awaiting Quality Check')).toHaveCount(0, {
62
+ timeout: 8000,
63
+ });
64
+
65
+ await itemRowByText(
66
+ page,
67
+ 'Add Sonnet to Opus weekly-limit fallback routing per token',
68
+ ).click();
69
+ await processSelectedItemViaStatus(page);
70
+ await expect(tabBadge(page, 'Triage')).toHaveText('1', { timeout: 8000 });
71
+ await page.locator('.console-back-button').click();
72
+
73
+ await itemRowByText(
74
+ page,
75
+ 'Publish the generated documentation site to GitHub Pages',
76
+ ).click();
77
+ await processSelectedItemViaStatus(page);
78
+
79
+ await expect(activeTabLabel(page)).toHaveText('Unread', { timeout: 8000 });
80
+ await expect(tabByLabel(page, 'Triage')).toHaveCount(0, {
81
+ timeout: 8000,
82
+ });
83
+
84
+ await tabByLabel(page, 'Todo by human').click();
85
+ await expect(activeTabLabel(page)).toHaveText('Todo by human');
86
+ await expect(tabByLabel(page, 'Triage')).toHaveCount(0);
87
+ await expect(tabBadge(page, 'Todo by human')).toHaveText('1');
88
+
89
+ await tabByLabel(page, 'Unread').click();
90
+ await expect(activeTabLabel(page)).toHaveText('Unread');
91
+ await expect(tabByLabel(page, 'Triage')).toHaveCount(0);
92
+ await expect(tabBadge(page, 'Unread')).toHaveText('2');
93
+ });
@@ -0,0 +1,380 @@
1
+ import * as fs from 'node:fs';
2
+ import type * as http from 'node:http';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { Issue } from '../../../../../domain/entities/Issue';
6
+ import type { Project } from '../../../../../domain/entities/Project';
7
+ import type {
8
+ IssueComment,
9
+ IssueRepository,
10
+ PullRequestCommit,
11
+ PullRequestDetail,
12
+ RelatedPullRequest,
13
+ } from '../../../../../domain/usecases/adapter-interfaces/IssueRepository';
14
+ import type { ConsoleProjectBinding } from '../../consoleOperationApi';
15
+ import { IssueTitleStateCache } from '../../consoleReadApi';
16
+ import { startConsoleServer } from '../../consoleServer';
17
+
18
+ export const CONSOLE_E2E_PJCODE = 'umino';
19
+ export const CONSOLE_E2E_TOKEN = 'console-e2e-fixture-token-3f9c1a';
20
+
21
+ type ConsoleFixtureListItem = {
22
+ number: number;
23
+ title: string;
24
+ url: string;
25
+ repo: string;
26
+ nameWithOwner: string;
27
+ projectItemId: string;
28
+ itemId: string;
29
+ isPr: boolean;
30
+ story: string;
31
+ labels: string[];
32
+ createdAt: string;
33
+ };
34
+
35
+ type ConsoleFixtureFieldOption = {
36
+ id: string;
37
+ name: string;
38
+ color: string;
39
+ };
40
+
41
+ type ConsoleFixtureSnapshot = {
42
+ pjcode: string;
43
+ generatedAt: string;
44
+ statusOptions: ConsoleFixtureFieldOption[];
45
+ storyOptions: ConsoleFixtureFieldOption[];
46
+ storyColors: Record<string, { color: string }>;
47
+ items: ConsoleFixtureListItem[];
48
+ };
49
+
50
+ const REPO_NAME_WITH_OWNER =
51
+ 'HiromiShikata/npm-cli-github-issue-tower-defence-management';
52
+
53
+ const AWAITING_WORKSPACE_OPTION: ConsoleFixtureFieldOption = {
54
+ id: 'd1c19cce',
55
+ name: 'Awaiting Workspace',
56
+ color: 'BLUE',
57
+ };
58
+
59
+ const STATUS_OPTIONS: ConsoleFixtureFieldOption[] = [
60
+ { id: 'f75ad846', name: 'Unread', color: 'ORANGE' },
61
+ AWAITING_WORKSPACE_OPTION,
62
+ { id: 'f57f1ce9', name: 'Preparation', color: 'YELLOW' },
63
+ { id: 'fd313492', name: 'Failed Preparation', color: 'RED' },
64
+ { id: 'e9931e57', name: 'Todo by human', color: 'PINK' },
65
+ { id: 'c2d278b2', name: 'In Tmux by human', color: 'RED' },
66
+ { id: 'e9f6a726', name: 'In Tmux by agent', color: 'YELLOW' },
67
+ ];
68
+
69
+ const STORY_OPTIONS: ConsoleFixtureFieldOption[] = [
70
+ { id: '1491051e', name: 'TDPM Console port', color: 'BLUE' },
71
+ { id: '28415d6c', name: 'regular / workflow improvement', color: 'GRAY' },
72
+ {
73
+ id: 'f7cd5cbc',
74
+ name: 'Publish product documentation site',
75
+ color: 'GREEN',
76
+ },
77
+ ];
78
+
79
+ const STORY_COLORS: Record<string, { color: string }> = {
80
+ 'TDPM Console port': { color: 'BLUE' },
81
+ 'regular / workflow improvement': { color: 'GRAY' },
82
+ 'Publish product documentation site': { color: 'GREEN' },
83
+ };
84
+
85
+ const issueItem = (
86
+ number: number,
87
+ title: string,
88
+ projectItemSuffix: string,
89
+ story: string,
90
+ createdAt: string,
91
+ ): ConsoleFixtureListItem => ({
92
+ number,
93
+ title,
94
+ url: `https://github.com/${REPO_NAME_WITH_OWNER}/issues/${number}`,
95
+ repo: REPO_NAME_WITH_OWNER,
96
+ nameWithOwner: REPO_NAME_WITH_OWNER,
97
+ projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
98
+ itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
99
+ isPr: false,
100
+ story,
101
+ labels: [],
102
+ createdAt,
103
+ });
104
+
105
+ const pullRequestItem = (
106
+ number: number,
107
+ title: string,
108
+ projectItemSuffix: string,
109
+ story: string,
110
+ createdAt: string,
111
+ ): ConsoleFixtureListItem => ({
112
+ number,
113
+ title,
114
+ url: `https://github.com/${REPO_NAME_WITH_OWNER}/pull/${number}`,
115
+ repo: REPO_NAME_WITH_OWNER,
116
+ nameWithOwner: REPO_NAME_WITH_OWNER,
117
+ projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
118
+ itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
119
+ isPr: true,
120
+ story,
121
+ labels: ['claude'],
122
+ createdAt,
123
+ });
124
+
125
+ const buildSnapshot = (
126
+ items: ConsoleFixtureListItem[],
127
+ ): ConsoleFixtureSnapshot => ({
128
+ pjcode: CONSOLE_E2E_PJCODE,
129
+ generatedAt: '2026-06-18T01:22:09.000Z',
130
+ statusOptions: STATUS_OPTIONS,
131
+ storyOptions: STORY_OPTIONS,
132
+ storyColors: STORY_COLORS,
133
+ items,
134
+ });
135
+
136
+ export const CONSOLE_E2E_TAB_ITEMS: Record<string, ConsoleFixtureListItem[]> = {
137
+ prs: [
138
+ pullRequestItem(
139
+ 867,
140
+ 'Serve the committed console UI bundle from serveConsole',
141
+ 'PRS00867',
142
+ 'TDPM Console port',
143
+ '2026-06-17T23:41:08.000Z',
144
+ ),
145
+ ],
146
+ triage: [
147
+ issueItem(
148
+ 778,
149
+ 'Add Sonnet to Opus weekly-limit fallback routing per token',
150
+ 'TRI00778',
151
+ 'regular / workflow improvement',
152
+ '2026-06-12T23:01:55.000Z',
153
+ ),
154
+ issueItem(
155
+ 692,
156
+ 'Publish the generated documentation site to GitHub Pages',
157
+ 'TRI00692',
158
+ 'Publish product documentation site',
159
+ '2026-06-10T11:42:00.000Z',
160
+ ),
161
+ ],
162
+ unread: [
163
+ issueItem(
164
+ 845,
165
+ 'Scaffold React console UI under entry-points with build bundling',
166
+ 'UNR00845',
167
+ 'TDPM Console port',
168
+ '2026-06-16T22:01:55.000Z',
169
+ ),
170
+ issueItem(
171
+ 853,
172
+ 'Add server-side console API handlers for read and operation endpoints',
173
+ 'UNR00853',
174
+ 'TDPM Console port',
175
+ '2026-06-17T05:48:09.000Z',
176
+ ),
177
+ ],
178
+ 'failed-preparation': [],
179
+ 'todo-by-human': [
180
+ issueItem(
181
+ 869,
182
+ 'Auto-advance to the next non-empty console tab when one empties',
183
+ 'TODO00869',
184
+ 'TDPM Console port',
185
+ '2026-06-18T00:14:51.000Z',
186
+ ),
187
+ ],
188
+ };
189
+
190
+ const writeFixtureData = (consoleDataOutputDir: string): void => {
191
+ for (const [tab, items] of Object.entries(CONSOLE_E2E_TAB_ITEMS)) {
192
+ const tabDir = path.join(consoleDataOutputDir, CONSOLE_E2E_PJCODE, tab);
193
+ fs.mkdirSync(tabDir, { recursive: true });
194
+ fs.writeFileSync(
195
+ path.join(tabDir, 'list.json'),
196
+ JSON.stringify(buildSnapshot(items)),
197
+ );
198
+ }
199
+ };
200
+
201
+ const buildE2eProject = (): Project => ({
202
+ id: 'PVT_console_e2e',
203
+ url: `https://github.com/orgs/HiromiShikata/projects/1`,
204
+ databaseId: 1,
205
+ name: 'TDPM',
206
+ status: {
207
+ name: 'Status',
208
+ fieldId: 'PVTSSF_status',
209
+ statuses: STATUS_OPTIONS.map((option) => ({
210
+ id: option.id,
211
+ name: option.name,
212
+ color: option.color as Project['status']['statuses'][number]['color'],
213
+ description: '',
214
+ })),
215
+ },
216
+ nextActionDate: { name: 'Next Action Date', fieldId: 'PVTF_nad' },
217
+ nextActionHour: { name: 'Next Action Hour', fieldId: 'PVTF_nah' },
218
+ story: {
219
+ name: 'Story',
220
+ fieldId: 'PVTSSF_story',
221
+ databaseId: 2,
222
+ stories: STORY_OPTIONS.map((option) => ({
223
+ id: option.id,
224
+ name: option.name,
225
+ color: option.color as Project['status']['statuses'][number]['color'],
226
+ description: '',
227
+ })),
228
+ workflowManagementStory: { id: 'wms_1', name: 'regular / workflow' },
229
+ },
230
+ remainingEstimationMinutes: null,
231
+ dependedIssueUrlSeparatedByComma: null,
232
+ completionDate50PercentConfidence: null,
233
+ });
234
+
235
+ const buildIssueForUrl = (url: string): Issue => ({
236
+ nameWithOwner: REPO_NAME_WITH_OWNER,
237
+ number: 0,
238
+ title: 'Console E2E fixture issue',
239
+ state: 'OPEN',
240
+ status: null,
241
+ story: null,
242
+ nextActionDate: null,
243
+ nextActionHour: null,
244
+ estimationMinutes: null,
245
+ dependedIssueUrls: [],
246
+ completionDate50PercentConfidence: null,
247
+ url,
248
+ assignees: [],
249
+ labels: [],
250
+ org: 'HiromiShikata',
251
+ repo: 'npm-cli-github-issue-tower-defence-management',
252
+ body: 'Console E2E fixture issue body.',
253
+ itemId: '',
254
+ isPr: url.includes('/pull/'),
255
+ isInProgress: false,
256
+ isClosed: false,
257
+ createdAt: new Date('2026-06-18T00:00:00.000Z'),
258
+ author: 'HiromiShikata',
259
+ });
260
+
261
+ const notImplemented = (method: string): never => {
262
+ throw new Error(`console E2E stub does not implement ${method}`);
263
+ };
264
+
265
+ const createStubIssueRepository = (): IssueRepository => ({
266
+ getAllIssues: () => notImplemented('getAllIssues'),
267
+ getIssueByUrl: async (url: string): Promise<Issue | null> =>
268
+ buildIssueForUrl(url),
269
+ createNewIssue: () => notImplemented('createNewIssue'),
270
+ searchIssue: () => notImplemented('searchIssue'),
271
+ updateIssue: () => notImplemented('updateIssue'),
272
+ updateNextActionDate: async (): Promise<void> => undefined,
273
+ updateNextActionHour: () => notImplemented('updateNextActionHour'),
274
+ updateProjectTextField: () => notImplemented('updateProjectTextField'),
275
+ updateStory: async (): Promise<void> => undefined,
276
+ updateStatus: async (): Promise<void> => undefined,
277
+ clearProjectField: () => notImplemented('clearProjectField'),
278
+ createComment: () => notImplemented('createComment'),
279
+ updateLabels: () => notImplemented('updateLabels'),
280
+ removeLabel: () => notImplemented('removeLabel'),
281
+ updateAssigneeList: () => notImplemented('updateAssigneeList'),
282
+ get: async (issueUrl: string): Promise<Issue | null> =>
283
+ buildIssueForUrl(issueUrl),
284
+ update: () => notImplemented('update'),
285
+ findRelatedOpenPRs: async (): Promise<RelatedPullRequest[]> => [],
286
+ getOpenPullRequest: async (): Promise<RelatedPullRequest | null> => null,
287
+ getPullRequestChangedFilePaths: async (): Promise<string[]> => [],
288
+ approvePullRequest: async (): Promise<void> => undefined,
289
+ requestChangesWithInlineComment: async (): Promise<void> => undefined,
290
+ closePullRequest: async (): Promise<void> => undefined,
291
+ closeIssueByUrl: async (): Promise<void> => undefined,
292
+ deletePullRequestBranch: () => notImplemented('deletePullRequestBranch'),
293
+ createCommentByUrl: async (): Promise<void> => undefined,
294
+ getAllOpened: () => notImplemented('getAllOpened'),
295
+ getStoryObjectMap: () => notImplemented('getStoryObjectMap'),
296
+ addIssueToProject: () => notImplemented('addIssueToProject'),
297
+ setDependedIssueUrl: () => notImplemented('setDependedIssueUrl'),
298
+ getIssueOrPullRequestBody: async (): Promise<string> =>
299
+ '## Console E2E fixture\n\nThis body is served by the isolated E2E stub.',
300
+ getIssueOrPullRequestComments: async (): Promise<IssueComment[]> => [],
301
+ getPullRequestDetail: async (): Promise<PullRequestDetail | null> => null,
302
+ getPullRequestCommits: async (): Promise<PullRequestCommit[]> => [],
303
+ getIssueOrPullRequestState: async (
304
+ url: string,
305
+ ): Promise<{ state: string; merged: boolean; isPullRequest: boolean }> => ({
306
+ state: 'open',
307
+ merged: false,
308
+ isPullRequest: url.includes('/pull/'),
309
+ }),
310
+ getPullRequestSummary: async (): Promise<{
311
+ title: string;
312
+ body: string;
313
+ additions: number;
314
+ deletions: number;
315
+ changedFiles: number;
316
+ } | null> => null,
317
+ });
318
+
319
+ export type ConsoleE2eHarness = {
320
+ baseUrl: string;
321
+ appUrl: string;
322
+ consoleDataOutputDir: string;
323
+ stop: () => Promise<void>;
324
+ };
325
+
326
+ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
327
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'console-e2e-'));
328
+ const consoleDataOutputDir = path.join(tmpRoot, 'data');
329
+ writeFixtureData(consoleDataOutputDir);
330
+
331
+ const uiDistDir = path.resolve(__dirname, '..', '..', 'ui-dist');
332
+
333
+ const project = buildE2eProject();
334
+ const resolveProject = async (
335
+ pjcode: string,
336
+ ): Promise<ConsoleProjectBinding | null> =>
337
+ pjcode === CONSOLE_E2E_PJCODE ? { pjcode, project } : null;
338
+
339
+ const server = await startConsoleServer({
340
+ accessToken: CONSOLE_E2E_TOKEN,
341
+ uiDistDir,
342
+ consoleDataOutputDir,
343
+ issueRepository: createStubIssueRepository(),
344
+ resolveProject,
345
+ issueTitleStateCache: new IssueTitleStateCache(),
346
+ inTmuxDataDir: null,
347
+ dashboardDir: null,
348
+ port: 0,
349
+ });
350
+
351
+ const address = server.address();
352
+ if (address === null || typeof address === 'string') {
353
+ await closeServer(server);
354
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
355
+ throw new Error('console E2E server is not listening on a TCP port');
356
+ }
357
+ const baseUrl = `http://127.0.0.1:${address.port}`;
358
+ const appUrl = `${baseUrl}/projects/${CONSOLE_E2E_PJCODE}?k=${CONSOLE_E2E_TOKEN}`;
359
+
360
+ return {
361
+ baseUrl,
362
+ appUrl,
363
+ consoleDataOutputDir,
364
+ stop: async (): Promise<void> => {
365
+ await closeServer(server);
366
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
367
+ },
368
+ };
369
+ };
370
+
371
+ const closeServer = (server: http.Server): Promise<void> =>
372
+ new Promise((resolve, reject) => {
373
+ server.close((error) => {
374
+ if (error) {
375
+ reject(error);
376
+ return;
377
+ }
378
+ resolve();
379
+ });
380
+ });
@@ -0,0 +1,30 @@
1
+ import path from 'node:path';
2
+ import { defineConfig, devices } from '@playwright/test';
3
+
4
+ export default defineConfig({
5
+ testDir: __dirname,
6
+ testMatch: '**/*.spec.ts',
7
+ fullyParallel: false,
8
+ forbidOnly: !!process.env.CI,
9
+ retries: 0,
10
+ workers: 1,
11
+ reporter: [
12
+ ['list'],
13
+ ['html', { open: 'never', outputFolder: path.join(__dirname, 'report') }],
14
+ ],
15
+ outputDir: path.join(__dirname, 'test-results'),
16
+ timeout: 60_000,
17
+ expect: { timeout: 3_000 },
18
+ use: {
19
+ actionTimeout: 3_000,
20
+ navigationTimeout: 3_000,
21
+ trace: 'on-first-retry',
22
+ screenshot: 'only-on-failure',
23
+ },
24
+ projects: [
25
+ {
26
+ name: 'chromium',
27
+ use: { ...devices['Desktop Chrome'] },
28
+ },
29
+ ],
30
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "CommonJS",
6
+ "moduleResolution": "Node",
7
+ "ignoreDeprecations": "6.0",
8
+ "esModuleInterop": true,
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["."]
18
+ }
@@ -100,7 +100,7 @@ describe('ConsolePage', () => {
100
100
  it('shows a cancellable toast and only drives the tab to zero after the five second window', async () => {
101
101
  jest.useFakeTimers();
102
102
  try {
103
- const { getByText, findByText } = render(<ConsolePage />);
103
+ const { getByText, findByText, queryByText } = render(<ConsolePage />);
104
104
  await waitFor(() => {
105
105
  expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
106
106
  });
@@ -127,9 +127,7 @@ describe('ConsolePage', () => {
127
127
  });
128
128
 
129
129
  await waitFor(() => {
130
- expect(
131
- getByText('Unread').closest('a')?.getAttribute('aria-current'),
132
- ).toBe('page');
130
+ expect(queryByText('Awaiting Quality Check')).toBeNull();
133
131
  });
134
132
  } finally {
135
133
  jest.useRealTimers();
@@ -53,7 +53,8 @@ export const ConsolePage = () => {
53
53
  const pjcode = useConsolePjcode();
54
54
  const { snapshots, isLoading, error } = useConsoleTabData(pjcode);
55
55
  const navigation = useConsoleNavigation(pjcode);
56
- const { activeTab, selectedItemKey } = navigation;
56
+ const { activeTab, selectedItemKey, openItem, closeItem, selectTab } =
57
+ navigation;
57
58
 
58
59
  const overlayState = useConsoleOverlay(pjcode ?? OVERLAY_NAMESPACE_FALLBACK);
59
60
  const caches = useConsoleCaches();
@@ -124,11 +125,11 @@ export const ConsolePage = () => {
124
125
  if (previous.count > 0 && activeCount === 0) {
125
126
  const nextTab = findNextNonEmptyTabToRight(activeTab, counts);
126
127
  if (nextTab !== null) {
127
- navigation.selectTab(nextTab);
128
- navigation.closeItem();
128
+ selectTab(nextTab);
129
+ closeItem();
129
130
  }
130
131
  }
131
- }, [activeTab, activeCount, counts, navigation]);
132
+ }, [activeTab, activeCount, counts, selectTab, closeItem]);
132
133
 
133
134
  const overlayStatusForSelected = ((): ConsoleOverlayStatus | null => {
134
135
  if (selectedItem === null) {
@@ -143,8 +144,6 @@ export const ConsolePage = () => {
143
144
  ? resolveItemStory(selectedItem, overlayState.overlay)
144
145
  : null;
145
146
 
146
- const { openItem, closeItem } = navigation;
147
-
148
147
  const advanceToNext = useCallback(
149
148
  (actedKey: string): void => {
150
149
  const nextKey = nextPendingKeyAfter(orderedPendingKeys, actedKey);