github-issue-tower-defence-management 2.7.0 → 2.8.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 (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/entry-points/cli/index.js +3 -1
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js +347 -0
  5. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js.map +1 -0
  6. package/bin/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/entry-points/console/webServer.js +28 -0
  9. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  10. package/jest.config.js +1 -0
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/index.ts +4 -1
  13. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.test.ts +658 -0
  14. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.ts +482 -0
  15. package/src/adapter/entry-points/console/ui/jest-globals.cjs +26 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +10 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +23 -16
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.stories.tsx +56 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.test.tsx +111 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.tsx +92 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +15 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +19 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +30 -1
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +10 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +1 -3
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +8 -7
  27. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.test.ts +304 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.ts +167 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +15 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +6 -1
  31. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +77 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +48 -3
  33. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.test.ts +51 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.ts +39 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +215 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +20 -1
  37. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +361 -0
  38. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +401 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +4 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +209 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +27 -2
  42. package/src/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
  43. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  44. package/src/adapter/entry-points/console/webServer.test.ts +189 -0
  45. package/src/adapter/entry-points/console/webServer.ts +38 -0
  46. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  47. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts +90 -0
  48. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts.map +1 -0
  49. package/types/adapter/entry-points/console/webServer.d.ts +1 -0
  50. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  51. package/bin/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
@@ -0,0 +1,401 @@
1
+ import type {
2
+ ConsoleChangedFile,
3
+ ConsoleComment,
4
+ ConsoleCommit,
5
+ ConsoleFieldOption,
6
+ ConsoleIssueState,
7
+ ConsoleListItem,
8
+ ConsolePullRequestStatus,
9
+ ConsoleRelatedPullRequest,
10
+ ConsoleStoryColorSource,
11
+ ConsoleStoryEntry,
12
+ ConsoleTabName,
13
+ } from '../logic/types';
14
+
15
+ export type AirplaneTabSnapshot = {
16
+ items: ConsoleListItem[];
17
+ generatedAt: string;
18
+ statusOptions: ConsoleFieldOption[];
19
+ storyOptions: ConsoleFieldOption[];
20
+ storyColors: ConsoleStoryColorSource;
21
+ stories: ConsoleStoryEntry[];
22
+ defaultNameWithOwner: string | null;
23
+ fromCache: boolean;
24
+ storyOrder: string[];
25
+ };
26
+
27
+ export type AirplaneItemSnapshot = {
28
+ body: string;
29
+ comments: ConsoleComment[];
30
+ state: ConsoleIssueState;
31
+ files: ConsoleChangedFile[] | null;
32
+ commits: ConsoleCommit[] | null;
33
+ prStatus: ConsolePullRequestStatus | null;
34
+ relatedPrs: ConsoleRelatedPullRequest[] | null;
35
+ };
36
+
37
+ export type AirplaneSnapshot = {
38
+ capturedAt: string;
39
+ tabs: Record<string, Record<ConsoleTabName, AirplaneTabSnapshot>>;
40
+ items: Record<string, AirplaneItemSnapshot>;
41
+ failures: string[];
42
+ };
43
+
44
+ const AIRPLANE_CACHE_NAME = 'tdpm-airplane-v1';
45
+ const AIRPLANE_SNAPSHOT_REQUEST_URL = '/airplane-snapshot-data';
46
+ const AIRPLANE_MODE_STORAGE_KEY = 'tdpm_airplane_mode_on';
47
+
48
+ export const readAirplaneModeFlag = (): boolean => {
49
+ if (typeof localStorage === 'undefined') {
50
+ return false;
51
+ }
52
+ return localStorage.getItem(AIRPLANE_MODE_STORAGE_KEY) === '1';
53
+ };
54
+
55
+ export const writeAirplaneModeFlag = (on: boolean): void => {
56
+ if (typeof localStorage === 'undefined') {
57
+ return;
58
+ }
59
+ if (on) {
60
+ localStorage.setItem(AIRPLANE_MODE_STORAGE_KEY, '1');
61
+ } else {
62
+ localStorage.removeItem(AIRPLANE_MODE_STORAGE_KEY);
63
+ }
64
+ };
65
+
66
+ export const storeAirplaneSnapshot = async (
67
+ snapshot: AirplaneSnapshot,
68
+ ): Promise<void> => {
69
+ if (typeof caches === 'undefined') {
70
+ throw new Error('Cache API unavailable');
71
+ }
72
+ try {
73
+ const cache = await caches.open(AIRPLANE_CACHE_NAME);
74
+ await cache.put(
75
+ AIRPLANE_SNAPSHOT_REQUEST_URL,
76
+ new Response(JSON.stringify(snapshot), {
77
+ headers: { 'Content-Type': 'application/json' },
78
+ }),
79
+ );
80
+ } catch (err) {
81
+ if (err instanceof Error && err.name === 'QuotaExceededError') {
82
+ throw new Error(
83
+ 'Not enough browser storage to save the offline snapshot. Free up storage and retry.',
84
+ );
85
+ }
86
+ throw err;
87
+ }
88
+ };
89
+
90
+ export const loadAirplaneSnapshot =
91
+ async (): Promise<AirplaneSnapshot | null> => {
92
+ if (typeof caches === 'undefined') {
93
+ return null;
94
+ }
95
+ const cache = await caches.open(AIRPLANE_CACHE_NAME);
96
+ const response = await cache.match(AIRPLANE_SNAPSHOT_REQUEST_URL);
97
+ if (response === undefined) {
98
+ return null;
99
+ }
100
+ return response.json() as Promise<AirplaneSnapshot>;
101
+ };
102
+
103
+ export const clearAirplaneSnapshot = async (): Promise<void> => {
104
+ if (typeof caches === 'undefined') {
105
+ return;
106
+ }
107
+ await caches.delete(AIRPLANE_CACHE_NAME);
108
+ };
109
+
110
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
111
+ value !== null && typeof value === 'object' && !Array.isArray(value);
112
+
113
+ const getString = (value: unknown): string =>
114
+ typeof value === 'string' ? value : '';
115
+
116
+ const getNumber = (value: unknown): number =>
117
+ typeof value === 'number' ? value : 0;
118
+
119
+ const getBoolean = (value: unknown): boolean => value === true;
120
+
121
+ const parseStringArray = (value: unknown): string[] =>
122
+ Array.isArray(value)
123
+ ? value.filter((entry): entry is string => typeof entry === 'string')
124
+ : [];
125
+
126
+ const parseMergeableStatus = (
127
+ value: unknown,
128
+ ): 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' => {
129
+ if (value === 'MERGEABLE') {
130
+ return 'MERGEABLE';
131
+ }
132
+ if (value === 'CONFLICTING') {
133
+ return 'CONFLICTING';
134
+ }
135
+ return 'UNKNOWN';
136
+ };
137
+
138
+ const parseListItem = (item: unknown): ConsoleListItem | null => {
139
+ if (!isRecord(item)) {
140
+ return null;
141
+ }
142
+ return {
143
+ number: getNumber(item.number),
144
+ title: getString(item.title),
145
+ url: getString(item.url),
146
+ repo: getString(item.repo),
147
+ nameWithOwner: getString(item.nameWithOwner),
148
+ projectItemId: getString(item.projectItemId),
149
+ itemId: getString(item.itemId),
150
+ isPr: getBoolean(item.isPr),
151
+ story: getString(item.story),
152
+ status: typeof item.status === 'string' ? item.status : null,
153
+ nextActionDate:
154
+ typeof item.nextActionDate === 'string' ? item.nextActionDate : null,
155
+ nextActionHour:
156
+ typeof item.nextActionHour === 'number' ? item.nextActionHour : null,
157
+ dependedIssueUrls: parseStringArray(item.dependedIssueUrls),
158
+ labels: parseStringArray(item.labels),
159
+ createdAt: getString(item.createdAt),
160
+ relatedOpenPullRequestUrls: parseStringArray(
161
+ item.relatedOpenPullRequestUrls,
162
+ ),
163
+ agent: typeof item.agent === 'string' ? item.agent : null,
164
+ };
165
+ };
166
+
167
+ const parseTabSnapshot = (payload: unknown): AirplaneTabSnapshot => {
168
+ if (!isRecord(payload)) {
169
+ return {
170
+ items: [],
171
+ generatedAt: '',
172
+ statusOptions: [],
173
+ storyOptions: [],
174
+ storyColors: {},
175
+ stories: [],
176
+ defaultNameWithOwner: null,
177
+ fromCache: false,
178
+ storyOrder: [],
179
+ };
180
+ }
181
+ const items: ConsoleListItem[] = Array.isArray(payload.items)
182
+ ? payload.items
183
+ .map(parseListItem)
184
+ .filter((item): item is ConsoleListItem => item !== null)
185
+ : [];
186
+ const statusOptions: ConsoleFieldOption[] = Array.isArray(
187
+ payload.statusOptions,
188
+ )
189
+ ? (payload.statusOptions.filter(isRecord) as ConsoleFieldOption[])
190
+ : [];
191
+ const storyOptions: ConsoleFieldOption[] = Array.isArray(payload.storyOptions)
192
+ ? (payload.storyOptions.filter(isRecord) as ConsoleFieldOption[])
193
+ : [];
194
+ const stories: ConsoleStoryEntry[] = Array.isArray(payload.stories)
195
+ ? (payload.stories.filter(isRecord) as unknown as ConsoleStoryEntry[])
196
+ : [];
197
+ const storyOrder: string[] = Array.isArray(payload.storyOrder)
198
+ ? payload.storyOrder.filter((s): s is string => typeof s === 'string')
199
+ : [];
200
+ return {
201
+ items,
202
+ generatedAt:
203
+ typeof payload.generatedAt === 'string' ? payload.generatedAt : '',
204
+ statusOptions,
205
+ storyOptions,
206
+ storyColors: isRecord(payload.storyColors)
207
+ ? (payload.storyColors as ConsoleStoryColorSource)
208
+ : {},
209
+ stories,
210
+ defaultNameWithOwner:
211
+ typeof payload.defaultNameWithOwner === 'string'
212
+ ? payload.defaultNameWithOwner
213
+ : null,
214
+ fromCache: false,
215
+ storyOrder,
216
+ };
217
+ };
218
+
219
+ const parseComment = (item: unknown): ConsoleComment | null => {
220
+ if (!isRecord(item)) {
221
+ return null;
222
+ }
223
+ return {
224
+ author: getString(item.author),
225
+ body: getString(item.body),
226
+ createdAt: getString(item.createdAt),
227
+ };
228
+ };
229
+
230
+ const parseChangedFile = (item: unknown): ConsoleChangedFile | null => {
231
+ if (!isRecord(item)) {
232
+ return null;
233
+ }
234
+ return {
235
+ path: getString(item.path),
236
+ additions: getNumber(item.additions),
237
+ deletions: getNumber(item.deletions),
238
+ status: getString(item.status),
239
+ patch: typeof item.patch === 'string' ? item.patch : null,
240
+ rawUrl: typeof item.rawUrl === 'string' ? item.rawUrl : null,
241
+ };
242
+ };
243
+
244
+ const parseCommit = (item: unknown): ConsoleCommit | null => {
245
+ if (!isRecord(item)) {
246
+ return null;
247
+ }
248
+ return {
249
+ sha: getString(item.sha),
250
+ message: getString(item.message),
251
+ author: getString(item.author),
252
+ authoredAt: getString(item.authoredAt),
253
+ };
254
+ };
255
+
256
+ const parsePrStatus = (item: unknown): ConsolePullRequestStatus => {
257
+ if (!isRecord(item)) {
258
+ return {
259
+ found: false,
260
+ isConflicted: false,
261
+ mergeableStatus: 'UNKNOWN',
262
+ isPassedAllCiJob: false,
263
+ isCiStateSuccess: false,
264
+ isBranchOutOfDate: false,
265
+ missingRequiredCheckNames: [],
266
+ };
267
+ }
268
+ return {
269
+ found: getBoolean(item.found),
270
+ isConflicted: getBoolean(item.isConflicted),
271
+ mergeableStatus: parseMergeableStatus(item.mergeableStatus),
272
+ isPassedAllCiJob: getBoolean(item.isPassedAllCiJob),
273
+ isCiStateSuccess: getBoolean(item.isCiStateSuccess),
274
+ isBranchOutOfDate: getBoolean(item.isBranchOutOfDate),
275
+ missingRequiredCheckNames: parseStringArray(item.missingRequiredCheckNames),
276
+ };
277
+ };
278
+
279
+ const parseRelatedPr = (item: unknown): ConsoleRelatedPullRequest | null => {
280
+ if (!isRecord(item)) {
281
+ return null;
282
+ }
283
+ const summary = isRecord(item.summary)
284
+ ? {
285
+ title: getString(item.summary.title),
286
+ body: getString(item.summary.body),
287
+ additions: getNumber(item.summary.additions),
288
+ deletions: getNumber(item.summary.deletions),
289
+ changedFiles: getNumber(item.summary.changedFiles),
290
+ }
291
+ : null;
292
+ return {
293
+ url: getString(item.url),
294
+ branchName: typeof item.branchName === 'string' ? item.branchName : null,
295
+ createdAt: getString(item.createdAt),
296
+ isDraft: getBoolean(item.isDraft),
297
+ isConflicted: getBoolean(item.isConflicted),
298
+ mergeableStatus: parseMergeableStatus(item.mergeableStatus),
299
+ isPassedAllCiJob: getBoolean(item.isPassedAllCiJob),
300
+ isCiStateSuccess: getBoolean(item.isCiStateSuccess),
301
+ isResolvedAllReviewComments: getBoolean(item.isResolvedAllReviewComments),
302
+ isBranchOutOfDate: getBoolean(item.isBranchOutOfDate),
303
+ missingRequiredCheckNames: parseStringArray(item.missingRequiredCheckNames),
304
+ summary,
305
+ };
306
+ };
307
+
308
+ const parseItemSnapshot = (item: unknown): AirplaneItemSnapshot => {
309
+ if (!isRecord(item)) {
310
+ return {
311
+ body: '',
312
+ comments: [],
313
+ state: { state: 'open', merged: false, isPullRequest: false, title: '' },
314
+ files: null,
315
+ commits: null,
316
+ prStatus: null,
317
+ relatedPrs: null,
318
+ };
319
+ }
320
+ const stateRaw = isRecord(item.state) ? item.state : {};
321
+ const state: ConsoleIssueState = {
322
+ state: getString(stateRaw.state) || 'open',
323
+ merged: getBoolean(stateRaw.merged),
324
+ isPullRequest: getBoolean(stateRaw.isPullRequest),
325
+ title: getString(stateRaw.title),
326
+ };
327
+ const files =
328
+ item.files === null
329
+ ? null
330
+ : Array.isArray(item.files)
331
+ ? item.files
332
+ .map(parseChangedFile)
333
+ .filter((f): f is ConsoleChangedFile => f !== null)
334
+ : null;
335
+ const commits =
336
+ item.commits === null
337
+ ? null
338
+ : Array.isArray(item.commits)
339
+ ? item.commits
340
+ .map(parseCommit)
341
+ .filter((c): c is ConsoleCommit => c !== null)
342
+ : null;
343
+ const prStatus = item.prStatus === null ? null : parsePrStatus(item.prStatus);
344
+ const relatedPrs =
345
+ item.relatedPrs === null
346
+ ? null
347
+ : Array.isArray(item.relatedPrs)
348
+ ? item.relatedPrs
349
+ .map(parseRelatedPr)
350
+ .filter((pr): pr is ConsoleRelatedPullRequest => pr !== null)
351
+ : null;
352
+ return {
353
+ body: getString(item.body),
354
+ comments: Array.isArray(item.comments)
355
+ ? item.comments
356
+ .map(parseComment)
357
+ .filter((c): c is ConsoleComment => c !== null)
358
+ : [],
359
+ state,
360
+ files,
361
+ commits,
362
+ prStatus,
363
+ relatedPrs,
364
+ };
365
+ };
366
+
367
+ export const parseAirplaneSnapshot = (
368
+ raw: unknown,
369
+ ): AirplaneSnapshot | null => {
370
+ if (!isRecord(raw)) {
371
+ return null;
372
+ }
373
+ const capturedAt = typeof raw.capturedAt === 'string' ? raw.capturedAt : '';
374
+ const failures = parseStringArray(raw.failures);
375
+
376
+ const tabs: Record<string, Record<ConsoleTabName, AirplaneTabSnapshot>> = {};
377
+ if (isRecord(raw.tabs)) {
378
+ for (const [pjcode, pjTabs] of Object.entries(raw.tabs)) {
379
+ if (!isRecord(pjTabs)) {
380
+ continue;
381
+ }
382
+ const parsedPjTabs: Record<string, AirplaneTabSnapshot> = {};
383
+ for (const [tabName, tabPayload] of Object.entries(pjTabs)) {
384
+ parsedPjTabs[tabName] = parseTabSnapshot(tabPayload);
385
+ }
386
+ tabs[pjcode] = parsedPjTabs as Record<
387
+ ConsoleTabName,
388
+ AirplaneTabSnapshot
389
+ >;
390
+ }
391
+ }
392
+
393
+ const items: Record<string, AirplaneItemSnapshot> = {};
394
+ if (isRecord(raw.items)) {
395
+ for (const [url, itemData] of Object.entries(raw.items)) {
396
+ items[url] = parseItemSnapshot(itemData);
397
+ }
398
+ }
399
+
400
+ return { capturedAt, tabs, items, failures };
401
+ };
@@ -51,6 +51,10 @@ export class ResourceCache<T> {
51
51
  });
52
52
  };
53
53
 
54
+ seed(key: string, value: T): void {
55
+ this.cache.set(key, value);
56
+ }
57
+
54
58
  invalidate = (key: string): void => {
55
59
  this.cache.delete(key);
56
60
  };
@@ -30,6 +30,15 @@ const listPayload = (tab: string) => ({
30
30
  statusOptions: [{ id: 's1', name: 'Awaiting Workspace', color: 'BLUE' }],
31
31
  storyOptions: [{ id: 'st1', name: 'TDPM Console port', color: 'BLUE' }],
32
32
  storyColors: { 'TDPM Console port': { color: 'BLUE' } },
33
+ stories: [
34
+ {
35
+ storyName: 'TDPM Console port',
36
+ storyOptionId: 'st1',
37
+ color: 'BLUE',
38
+ openItemCount: 1,
39
+ storyViewUrl: null,
40
+ },
41
+ ],
33
42
  items:
34
43
  tab === 'prs'
35
44
  ? [
@@ -516,6 +525,30 @@ describe('ConsolePage', () => {
516
525
  jest.useRealTimers();
517
526
  }
518
527
  });
528
+
529
+ it('renders reorder buttons in the Stories tab', async () => {
530
+ window.history.replaceState({}, '', '/projects/acme/stories?k=token');
531
+ const { getAllByRole, getByText } = render(<ConsolePage />);
532
+ await waitFor(() => {
533
+ expect(
534
+ getByText('snapshot: 2026-06-19T00:00:00.000Z'),
535
+ ).toBeInTheDocument();
536
+ });
537
+ expect(getAllByRole('button', { name: 'Move up' }).length).toBeGreaterThan(
538
+ 0,
539
+ );
540
+ });
541
+
542
+ it('does not render reorder buttons in the Triage tab', async () => {
543
+ window.history.replaceState({}, '', '/projects/acme/triage?k=token');
544
+ const { queryAllByRole, getByText } = render(<ConsolePage />);
545
+ await waitFor(() => {
546
+ expect(
547
+ getByText('snapshot: 2026-06-19T00:00:00.000Z'),
548
+ ).toBeInTheDocument();
549
+ });
550
+ expect(queryAllByRole('button', { name: 'Move up' })).toHaveLength(0);
551
+ });
519
552
  });
520
553
 
521
554
  const twoItemPrPayload = () => ({
@@ -1200,3 +1233,179 @@ describe('ConsolePage auto-advance tab', () => {
1200
1233
  }
1201
1234
  });
1202
1235
  });
1236
+
1237
+ describe('ConsolePage airplane mode write guard', () => {
1238
+ const prItem = {
1239
+ number: 851,
1240
+ title: 'Add serveConsole subcommand',
1241
+ url: 'https://github.com/o/r/pull/851',
1242
+ repo: 'o/r',
1243
+ nameWithOwner: 'o/r',
1244
+ projectItemId: 'PVTI_1',
1245
+ itemId: 'PVTI_1',
1246
+ isPr: true,
1247
+ relatedOpenPullRequestUrls: [],
1248
+ story: 'TDPM Console port',
1249
+ status: 'Awaiting Quality Check',
1250
+ nextActionDate: null,
1251
+ nextActionHour: null,
1252
+ dependedIssueUrls: [],
1253
+ labels: [],
1254
+ createdAt: '2026-06-17T00:00:00.000Z',
1255
+ agent: null,
1256
+ };
1257
+
1258
+ const airplaneSnapshot = {
1259
+ capturedAt: '2026-06-19T00:00:00.000Z',
1260
+ tabs: {
1261
+ acme: {
1262
+ prs: {
1263
+ items: [prItem],
1264
+ generatedAt: '2026-06-19T00:00:00.000Z',
1265
+ statusOptions: [
1266
+ { id: 's1', name: 'Awaiting Workspace', color: 'BLUE' },
1267
+ ],
1268
+ storyOptions: [
1269
+ { id: 'st1', name: 'TDPM Console port', color: 'BLUE' },
1270
+ ],
1271
+ storyColors: { 'TDPM Console port': { color: 'BLUE' } },
1272
+ stories: [],
1273
+ defaultNameWithOwner: 'o/r',
1274
+ fromCache: false,
1275
+ storyOrder: [],
1276
+ },
1277
+ 'workflow-blocker': {
1278
+ items: [],
1279
+ generatedAt: '',
1280
+ statusOptions: [],
1281
+ storyOptions: [],
1282
+ storyColors: {},
1283
+ stories: [],
1284
+ defaultNameWithOwner: null,
1285
+ fromCache: false,
1286
+ storyOrder: [],
1287
+ },
1288
+ 'failed-preparation': {
1289
+ items: [],
1290
+ generatedAt: '',
1291
+ statusOptions: [],
1292
+ storyOptions: [],
1293
+ storyColors: {},
1294
+ stories: [],
1295
+ defaultNameWithOwner: null,
1296
+ fromCache: false,
1297
+ storyOrder: [],
1298
+ },
1299
+ 'todo-by-human': {
1300
+ items: [],
1301
+ generatedAt: '',
1302
+ statusOptions: [],
1303
+ storyOptions: [],
1304
+ storyColors: {},
1305
+ stories: [],
1306
+ defaultNameWithOwner: null,
1307
+ fromCache: false,
1308
+ storyOrder: [],
1309
+ },
1310
+ 'todo-by-agent': {
1311
+ items: [],
1312
+ generatedAt: '',
1313
+ statusOptions: [],
1314
+ storyOptions: [],
1315
+ storyColors: {},
1316
+ stories: [],
1317
+ defaultNameWithOwner: null,
1318
+ fromCache: false,
1319
+ storyOrder: [],
1320
+ },
1321
+ stories: {
1322
+ items: [],
1323
+ generatedAt: '',
1324
+ statusOptions: [],
1325
+ storyOptions: [],
1326
+ storyColors: {},
1327
+ stories: [],
1328
+ defaultNameWithOwner: null,
1329
+ fromCache: false,
1330
+ storyOrder: [],
1331
+ },
1332
+ },
1333
+ },
1334
+ items: {},
1335
+ failures: [],
1336
+ };
1337
+
1338
+ beforeEach(() => {
1339
+ localStorage.clear();
1340
+ window.history.replaceState({}, '', '/projects/acme/prs?k=token');
1341
+
1342
+ localStorage.setItem('tdpm_airplane_mode_on', '1');
1343
+
1344
+ const snapshotJson = JSON.stringify(airplaneSnapshot);
1345
+ const mockCache = {
1346
+ put: jest.fn(),
1347
+ match: jest.fn().mockResolvedValue(
1348
+ new Response(snapshotJson, {
1349
+ headers: { 'Content-Type': 'application/json' },
1350
+ }),
1351
+ ),
1352
+ delete: jest.fn(),
1353
+ };
1354
+ Object.defineProperty(global, 'caches', {
1355
+ writable: true,
1356
+ configurable: true,
1357
+ value: {
1358
+ open: jest.fn().mockResolvedValue(mockCache),
1359
+ delete: jest.fn().mockResolvedValue(true),
1360
+ },
1361
+ });
1362
+
1363
+ global.fetch = jest.fn(async (url: string) => {
1364
+ if (url === '/api/features') {
1365
+ return {
1366
+ ok: true,
1367
+ status: 200,
1368
+ json: async () => ({ airplaneMode: true }),
1369
+ };
1370
+ }
1371
+ if (url === '/api/projects') {
1372
+ return {
1373
+ ok: true,
1374
+ status: 200,
1375
+ json: async () => ({ pjcodes: ['acme'] }),
1376
+ };
1377
+ }
1378
+ return { ok: true, status: 200, json: async () => ({ body: '# body' }) };
1379
+ }) as unknown as typeof fetch;
1380
+ });
1381
+
1382
+ afterEach(() => {
1383
+ Object.defineProperty(global, 'caches', {
1384
+ writable: true,
1385
+ configurable: true,
1386
+ value: undefined,
1387
+ });
1388
+ });
1389
+
1390
+ it('shows an airplane mode error toast instead of enqueuing when airplane mode is on', async () => {
1391
+ const { getByText, findByText } = render(<ConsolePage />);
1392
+
1393
+ await waitFor(() => {
1394
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
1395
+ });
1396
+
1397
+ await waitFor(() => {
1398
+ expect(getByText('Turn off')).toBeInTheDocument();
1399
+ });
1400
+
1401
+ fireEvent.click(getByText('Add serveConsole subcommand'));
1402
+ expect(await findByText('Approve & Merge')).toBeInTheDocument();
1403
+ fireEvent.click(getByText('Approve & Merge'));
1404
+
1405
+ await waitFor(() => {
1406
+ expect(getByText('Airplane mode')).toBeInTheDocument();
1407
+ });
1408
+ expect(getByText(/network connection/i)).toBeInTheDocument();
1409
+ expect(document.querySelector('.console-undo-toast')).toBeNull();
1410
+ });
1411
+ });