github-issue-tower-defence-management 1.94.4 → 1.96.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.
- package/.github/workflows/console-ui.yml +17 -0
- package/CHANGELOG.md +14 -0
- package/README.md +11 -0
- package/bin/adapter/entry-points/cli/index.js +21 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-B3F4E8LD.js +101 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
- package/package.json +4 -1
- package/src/adapter/entry-points/cli/index.ts +38 -0
- package/src/adapter/entry-points/console/ui/biome.json +7 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
- package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
- package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +48 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.ts +19 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +39 -6
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +31 -4
- package/src/adapter/entry-points/console/ui-dist/assets/index-B3F4E8LD.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +0 -101
- package/src/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +0 -101
|
@@ -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
|
+
}
|
|
@@ -59,6 +59,20 @@ export const ZeroCountActiveTabStaysVisible: Story = {
|
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
export const AfterAutoAdvanceToNextTab: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
activeTab: 'unread',
|
|
65
|
+
counts: {
|
|
66
|
+
prs: 0,
|
|
67
|
+
triage: 0,
|
|
68
|
+
unread: 7,
|
|
69
|
+
'failed-preparation': 2,
|
|
70
|
+
'todo-by-human': 4,
|
|
71
|
+
},
|
|
72
|
+
onSelectTab: () => {},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
62
76
|
export const Interactive: Story = {
|
|
63
77
|
render: (args) => {
|
|
64
78
|
const [activeTab, setActiveTab] = useState<ConsoleTabName>('prs');
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { findNextNonEmptyTabToRight } from './tabAdvance';
|
|
2
|
+
import type { ConsoleTabName } from './types';
|
|
3
|
+
|
|
4
|
+
const counts = (
|
|
5
|
+
overrides: Partial<Record<ConsoleTabName, number>>,
|
|
6
|
+
): Record<ConsoleTabName, number> => ({
|
|
7
|
+
prs: 0,
|
|
8
|
+
triage: 0,
|
|
9
|
+
unread: 0,
|
|
10
|
+
'failed-preparation': 0,
|
|
11
|
+
'todo-by-human': 0,
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('findNextNonEmptyTabToRight', () => {
|
|
16
|
+
it('returns the first non-empty tab to the right of the active tab', () => {
|
|
17
|
+
expect(findNextNonEmptyTabToRight('prs', counts({ unread: 7 }))).toBe(
|
|
18
|
+
'unread',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('skips empty tabs and returns the next non-empty tab further right', () => {
|
|
23
|
+
expect(
|
|
24
|
+
findNextNonEmptyTabToRight(
|
|
25
|
+
'prs',
|
|
26
|
+
counts({ triage: 0, unread: 0, 'todo-by-human': 4 }),
|
|
27
|
+
),
|
|
28
|
+
).toBe('todo-by-human');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns the immediately adjacent tab when it is non-empty', () => {
|
|
32
|
+
expect(
|
|
33
|
+
findNextNonEmptyTabToRight('prs', counts({ triage: 12, unread: 7 })),
|
|
34
|
+
).toBe('triage');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns null when no tab to the right has any items', () => {
|
|
38
|
+
expect(
|
|
39
|
+
findNextNonEmptyTabToRight('unread', counts({ prs: 35 })),
|
|
40
|
+
).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null when the active tab is the last tab', () => {
|
|
44
|
+
expect(
|
|
45
|
+
findNextNonEmptyTabToRight('todo-by-human', counts({ prs: 35 })),
|
|
46
|
+
).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConsoleTabName } from './types';
|
|
2
|
+
import { CONSOLE_TABS } from './types';
|
|
3
|
+
|
|
4
|
+
export const findNextNonEmptyTabToRight = (
|
|
5
|
+
activeTab: ConsoleTabName,
|
|
6
|
+
counts: Record<ConsoleTabName, number>,
|
|
7
|
+
): ConsoleTabName | null => {
|
|
8
|
+
const activeIndex = CONSOLE_TABS.findIndex((tab) => tab.name === activeTab);
|
|
9
|
+
if (activeIndex === -1) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
for (let index = activeIndex + 1; index < CONSOLE_TABS.length; index += 1) {
|
|
13
|
+
const candidate = CONSOLE_TABS[index].name;
|
|
14
|
+
if ((counts[candidate] ?? 0) > 0) {
|
|
15
|
+
return candidate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
};
|
|
@@ -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,11 +127,7 @@ describe('ConsolePage', () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
await waitFor(() => {
|
|
130
|
-
expect(
|
|
131
|
-
getByText('Awaiting Quality Check')
|
|
132
|
-
.closest('a')
|
|
133
|
-
?.querySelector('.console-tab-badge')?.textContent,
|
|
134
|
-
).toBe('0');
|
|
130
|
+
expect(queryByText('Awaiting Quality Check')).toBeNull();
|
|
135
131
|
});
|
|
136
132
|
} finally {
|
|
137
133
|
jest.useRealTimers();
|
|
@@ -397,3 +393,40 @@ describe('ConsolePage auto-advance', () => {
|
|
|
397
393
|
}
|
|
398
394
|
});
|
|
399
395
|
});
|
|
396
|
+
|
|
397
|
+
describe('ConsolePage auto-advance tab', () => {
|
|
398
|
+
beforeEach(() => {
|
|
399
|
+
localStorage.clear();
|
|
400
|
+
window.history.replaceState({}, '', '/projects/umino/prs?k=token');
|
|
401
|
+
installFetch();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('auto-advances to the next non-empty tab on the right after the active tab is driven to zero', async () => {
|
|
405
|
+
jest.useFakeTimers();
|
|
406
|
+
try {
|
|
407
|
+
const { getByText, findByText } = render(<ConsolePage />);
|
|
408
|
+
await waitFor(() => {
|
|
409
|
+
expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
fireEvent.click(getByText('Add serveConsole subcommand'));
|
|
413
|
+
expect(await findByText('← Back to list')).toBeInTheDocument();
|
|
414
|
+
fireEvent.click(getByText('Approve'));
|
|
415
|
+
|
|
416
|
+
act(() => {
|
|
417
|
+
jest.advanceTimersByTime(5100);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await waitFor(() => {
|
|
421
|
+
expect(
|
|
422
|
+
getByText('Notify finished issue preparation'),
|
|
423
|
+
).toBeInTheDocument();
|
|
424
|
+
});
|
|
425
|
+
expect(
|
|
426
|
+
getByText('Unread').closest('a')?.getAttribute('aria-current'),
|
|
427
|
+
).toBe('page');
|
|
428
|
+
} finally {
|
|
429
|
+
jest.useRealTimers();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
2
|
import { ConsoleTabList } from '../components/layout/ConsoleTabList';
|
|
3
3
|
import { ConsoleItemList } from '../components/list/ConsoleItemList';
|
|
4
4
|
import { ConsoleUndoToast } from '../components/operations/ConsoleUndoToast';
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
overlayKeyForItem,
|
|
28
28
|
} from '../logic/overlay';
|
|
29
29
|
import type { ConsoleSwipeDirection } from '../logic/swipe';
|
|
30
|
+
import { findNextNonEmptyTabToRight } from '../logic/tabAdvance';
|
|
30
31
|
import type {
|
|
31
32
|
ConsoleListItem,
|
|
32
33
|
ConsoleOverlayStatus,
|
|
@@ -52,7 +53,8 @@ export const ConsolePage = () => {
|
|
|
52
53
|
const pjcode = useConsolePjcode();
|
|
53
54
|
const { snapshots, isLoading, error } = useConsoleTabData(pjcode);
|
|
54
55
|
const navigation = useConsoleNavigation(pjcode);
|
|
55
|
-
const { activeTab, selectedItemKey } =
|
|
56
|
+
const { activeTab, selectedItemKey, openItem, closeItem, selectTab } =
|
|
57
|
+
navigation;
|
|
56
58
|
|
|
57
59
|
const overlayState = useConsoleOverlay(pjcode ?? OVERLAY_NAMESPACE_FALLBACK);
|
|
58
60
|
const caches = useConsoleCaches();
|
|
@@ -109,6 +111,26 @@ export const ConsolePage = () => {
|
|
|
109
111
|
);
|
|
110
112
|
}, [selectedItemKey, activeSnapshot]);
|
|
111
113
|
|
|
114
|
+
const activeCount = counts[activeTab];
|
|
115
|
+
const previousActiveTabCountRef = useRef<{
|
|
116
|
+
tab: ConsoleTabName;
|
|
117
|
+
count: number;
|
|
118
|
+
}>({ tab: activeTab, count: activeCount });
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const previous = previousActiveTabCountRef.current;
|
|
121
|
+
previousActiveTabCountRef.current = { tab: activeTab, count: activeCount };
|
|
122
|
+
if (previous.tab !== activeTab) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (previous.count > 0 && activeCount === 0) {
|
|
126
|
+
const nextTab = findNextNonEmptyTabToRight(activeTab, counts);
|
|
127
|
+
if (nextTab !== null) {
|
|
128
|
+
selectTab(nextTab);
|
|
129
|
+
closeItem();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}, [activeTab, activeCount, counts, selectTab, closeItem]);
|
|
133
|
+
|
|
112
134
|
const overlayStatusForSelected = ((): ConsoleOverlayStatus | null => {
|
|
113
135
|
if (selectedItem === null) {
|
|
114
136
|
return null;
|
|
@@ -122,8 +144,6 @@ export const ConsolePage = () => {
|
|
|
122
144
|
? resolveItemStory(selectedItem, overlayState.overlay)
|
|
123
145
|
: null;
|
|
124
146
|
|
|
125
|
-
const { openItem, closeItem } = navigation;
|
|
126
|
-
|
|
127
147
|
const advanceToNext = useCallback(
|
|
128
148
|
(actedKey: string): void => {
|
|
129
149
|
const nextKey = nextPendingKeyAfter(orderedPendingKeys, actedKey);
|
|
@@ -202,6 +222,13 @@ export const ConsolePage = () => {
|
|
|
202
222
|
/>
|
|
203
223
|
) : (
|
|
204
224
|
<div className="console-detail-screen" ref={detailScreenRef}>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className="console-back-button"
|
|
228
|
+
onClick={closeItem}
|
|
229
|
+
>
|
|
230
|
+
← Back to list
|
|
231
|
+
</button>
|
|
205
232
|
<ConsoleItemDetailContainer
|
|
206
233
|
tab={activeTab}
|
|
207
234
|
item={selectedItem}
|