github-issue-tower-defence-management 1.106.0 → 1.107.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 (26) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +5 -0
  3. package/bin/adapter/entry-points/cli/index.js +18 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +178 -0
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
  7. package/bin/adapter/entry-points/console/webServer.js +29 -7
  8. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  9. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +161 -0
  10. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/index.ts +36 -2
  13. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +443 -0
  14. package/src/adapter/entry-points/console/dashboardComposeService.ts +200 -0
  15. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  16. package/src/adapter/entry-points/console/webServer.test.ts +255 -54
  17. package/src/adapter/entry-points/console/webServer.ts +43 -10
  18. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +405 -0
  19. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +220 -0
  20. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  21. package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
  22. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
  23. package/types/adapter/entry-points/console/webServer.d.ts +4 -0
  24. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  25. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +28 -0
  26. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -0
@@ -0,0 +1,443 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ buildComposeDashboardInput,
6
+ composeDashboardText,
7
+ dashboardComposeFilesPresent,
8
+ } from './dashboardComposeService';
9
+
10
+ const makeDataDir = (): string =>
11
+ fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-compose-'));
12
+
13
+ const writeProject = (dataDir: string, code: string, body: unknown): void => {
14
+ fs.mkdirSync(path.join(dataDir, 'projects'), { recursive: true });
15
+ fs.writeFileSync(
16
+ path.join(dataDir, 'projects', `${code}.json`),
17
+ JSON.stringify(body),
18
+ );
19
+ };
20
+
21
+ describe('buildComposeDashboardInput', () => {
22
+ it('reads every requested project code in order and yields null for absent files', () => {
23
+ const dataDir = makeDataDir();
24
+ try {
25
+ writeProject(dataDir, 'um', {
26
+ pjcode: 'um',
27
+ capturedAt: 'x',
28
+ unread: 3,
29
+ todo: 1,
30
+ qc: 2,
31
+ fail: 0,
32
+ pr: 0,
33
+ ws: 4,
34
+ dep: 1,
35
+ blocker: 0,
36
+ });
37
+ const input = buildComposeDashboardInput({
38
+ dashboardDataDir: dataDir,
39
+ projectCodes: ['um', 'xc'],
40
+ });
41
+ expect(input.projects).toEqual([
42
+ {
43
+ code: 'um',
44
+ row: {
45
+ unread: 3,
46
+ todo: 1,
47
+ qc: 2,
48
+ fail: 0,
49
+ pr: 0,
50
+ ws: 4,
51
+ dep: 1,
52
+ blocker: 0,
53
+ },
54
+ },
55
+ { code: 'xc', row: null },
56
+ ]);
57
+ } finally {
58
+ fs.rmSync(dataDir, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ it('treats a project file with a missing field as an absent row', () => {
63
+ const dataDir = makeDataDir();
64
+ try {
65
+ writeProject(dataDir, 'um', { unread: 1, todo: 1 });
66
+ const input = buildComposeDashboardInput({
67
+ dashboardDataDir: dataDir,
68
+ projectCodes: ['um'],
69
+ });
70
+ expect(input.projects[0].row).toBeNull();
71
+ } finally {
72
+ fs.rmSync(dataDir, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ it('treats malformed JSON as an absent row', () => {
77
+ const dataDir = makeDataDir();
78
+ try {
79
+ fs.mkdirSync(path.join(dataDir, 'projects'), { recursive: true });
80
+ fs.writeFileSync(path.join(dataDir, 'projects', 'um.json'), 'not json');
81
+ const input = buildComposeDashboardInput({
82
+ dashboardDataDir: dataDir,
83
+ projectCodes: ['um'],
84
+ });
85
+ expect(input.projects[0].row).toBeNull();
86
+ } finally {
87
+ fs.rmSync(dataDir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it('reads machine status from machine-status.json', () => {
92
+ const dataDir = makeDataDir();
93
+ try {
94
+ fs.writeFileSync(
95
+ path.join(dataDir, 'machine-status.json'),
96
+ JSON.stringify({
97
+ memPct: 55,
98
+ cpuPct: 62,
99
+ load: [16, 23, 40],
100
+ cycleMinutes: 14,
101
+ capturedAt: 'x',
102
+ }),
103
+ );
104
+ const input = buildComposeDashboardInput({
105
+ dashboardDataDir: dataDir,
106
+ projectCodes: [],
107
+ });
108
+ expect(input.machineStatus).toEqual({
109
+ memPct: 55,
110
+ cpuPct: 62,
111
+ load: [16, 23, 40],
112
+ cycleMinutes: 14,
113
+ });
114
+ } finally {
115
+ fs.rmSync(dataDir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ it('preserves a null cycleMinutes from machine-status.json', () => {
120
+ const dataDir = makeDataDir();
121
+ try {
122
+ fs.writeFileSync(
123
+ path.join(dataDir, 'machine-status.json'),
124
+ JSON.stringify({
125
+ memPct: 1,
126
+ cpuPct: 2,
127
+ load: [0, 0, 0],
128
+ cycleMinutes: null,
129
+ capturedAt: 'x',
130
+ }),
131
+ );
132
+ const input = buildComposeDashboardInput({
133
+ dashboardDataDir: dataDir,
134
+ projectCodes: [],
135
+ });
136
+ expect(input.machineStatus?.cycleMinutes).toBeNull();
137
+ } finally {
138
+ fs.rmSync(dataDir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ it('yields a null machine status when the file is absent', () => {
143
+ const dataDir = makeDataDir();
144
+ try {
145
+ const input = buildComposeDashboardInput({
146
+ dashboardDataDir: dataDir,
147
+ projectCodes: [],
148
+ });
149
+ expect(input.machineStatus).toBeNull();
150
+ } finally {
151
+ fs.rmSync(dataDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ it('reads token statuses from token-status.json and skips malformed entries', () => {
156
+ const dataDir = makeDataDir();
157
+ try {
158
+ fs.writeFileSync(
159
+ path.join(dataDir, 'token-status.json'),
160
+ JSON.stringify({
161
+ tokens: [
162
+ {
163
+ name: 'alice',
164
+ fiveHourUtilizationPercent: 10,
165
+ fiveHourResetSeconds: 3600,
166
+ sevenDayUtilizationPercent: 12,
167
+ sevenDayResetSeconds: 432000,
168
+ color: 'G',
169
+ prep: 2,
170
+ hum: 1,
171
+ },
172
+ { fiveHourUtilizationPercent: 5 },
173
+ ],
174
+ capturedAt: 'x',
175
+ }),
176
+ );
177
+ const input = buildComposeDashboardInput({
178
+ dashboardDataDir: dataDir,
179
+ projectCodes: [],
180
+ });
181
+ expect(input.tokens).toEqual([
182
+ {
183
+ name: 'alice',
184
+ fiveHourUtilizationPercent: 10,
185
+ fiveHourResetSeconds: 3600,
186
+ sevenDayUtilizationPercent: 12,
187
+ sevenDayResetSeconds: 432000,
188
+ color: 'G',
189
+ prep: 2,
190
+ hum: 1,
191
+ },
192
+ ]);
193
+ } finally {
194
+ fs.rmSync(dataDir, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ it('defaults an unknown token color to Y and null windows to null', () => {
199
+ const dataDir = makeDataDir();
200
+ try {
201
+ fs.writeFileSync(
202
+ path.join(dataDir, 'token-status.json'),
203
+ JSON.stringify({
204
+ tokens: [
205
+ {
206
+ name: 'bob',
207
+ fiveHourUtilizationPercent: null,
208
+ fiveHourResetSeconds: null,
209
+ sevenDayUtilizationPercent: null,
210
+ sevenDayResetSeconds: null,
211
+ color: 'purple',
212
+ prep: 0,
213
+ hum: 0,
214
+ },
215
+ ],
216
+ capturedAt: 'x',
217
+ }),
218
+ );
219
+ const input = buildComposeDashboardInput({
220
+ dashboardDataDir: dataDir,
221
+ projectCodes: [],
222
+ });
223
+ expect(input.tokens[0].color).toBe('Y');
224
+ expect(input.tokens[0].fiveHourUtilizationPercent).toBeNull();
225
+ } finally {
226
+ fs.rmSync(dataDir, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ it('yields an empty token list when the file is absent', () => {
231
+ const dataDir = makeDataDir();
232
+ try {
233
+ const input = buildComposeDashboardInput({
234
+ dashboardDataDir: dataDir,
235
+ projectCodes: [],
236
+ });
237
+ expect(input.tokens).toEqual([]);
238
+ } finally {
239
+ fs.rmSync(dataDir, { recursive: true, force: true });
240
+ }
241
+ });
242
+ });
243
+
244
+ describe('composeDashboardText', () => {
245
+ it('composes the full byte-identical dashboard text from the data files', () => {
246
+ const dataDir = makeDataDir();
247
+ try {
248
+ writeProject(dataDir, 'um', {
249
+ pjcode: 'um',
250
+ capturedAt: 'x',
251
+ unread: 3,
252
+ todo: 1,
253
+ qc: 2,
254
+ fail: 0,
255
+ pr: 0,
256
+ ws: 4,
257
+ dep: 1,
258
+ blocker: 0,
259
+ });
260
+ fs.writeFileSync(
261
+ path.join(dataDir, 'machine-status.json'),
262
+ JSON.stringify({
263
+ memPct: 55,
264
+ cpuPct: 62,
265
+ load: [16, 23, 40],
266
+ cycleMinutes: 14,
267
+ capturedAt: 'x',
268
+ }),
269
+ );
270
+ fs.writeFileSync(
271
+ path.join(dataDir, 'token-status.json'),
272
+ JSON.stringify({
273
+ tokens: [
274
+ {
275
+ name: 'alice',
276
+ fiveHourUtilizationPercent: 10,
277
+ fiveHourResetSeconds: 3600,
278
+ sevenDayUtilizationPercent: 12,
279
+ sevenDayResetSeconds: 432000,
280
+ color: 'G',
281
+ prep: 2,
282
+ hum: 1,
283
+ },
284
+ ],
285
+ capturedAt: 'x',
286
+ }),
287
+ );
288
+ expect(
289
+ composeDashboardText({
290
+ dashboardDataDir: dataDir,
291
+ projectCodes: ['um', 'xc'],
292
+ }),
293
+ ).toBe(
294
+ '<tt>M55%&nbsp;C62%&nbsp;LA&nbsp;16&nbsp;23&nbsp;40&nbsp;cy14</tt><br>\n' +
295
+ '<tt>pj&nbsp;&nbsp;&nbsp;unr&nbsp;tdo&nbsp;aqc&nbsp;fal&nbsp;prp&nbsp;aws&nbsp;dep</tt><br>\n' +
296
+ '<tt>🟢um&nbsp;&nbsp;&nbsp;3&nbsp;&nbsp;&nbsp;1&nbsp;&nbsp;&nbsp;2&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;4&nbsp;&nbsp;&nbsp;1</tt><br>\n' +
297
+ '<tt>&nbsp;&nbsp;xc&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--&nbsp;&nbsp;--</tt><br>\n' +
298
+ '<tt></tt><br>\n' +
299
+ '<tt>🟢alice&nbsp;&nbsp;10%&nbsp;0d01h00&nbsp;&nbsp;12%&nbsp;5d00h00&nbsp;2&nbsp;1</tt><br>\n',
300
+ );
301
+ } finally {
302
+ fs.rmSync(dataDir, { recursive: true, force: true });
303
+ }
304
+ });
305
+ });
306
+
307
+ describe('dashboardComposeFilesPresent', () => {
308
+ const writeMachineAndToken = (dataDir: string): void => {
309
+ fs.writeFileSync(
310
+ path.join(dataDir, 'machine-status.json'),
311
+ JSON.stringify({
312
+ memPct: 1,
313
+ cpuPct: 2,
314
+ load: [0, 0, 0],
315
+ cycleMinutes: null,
316
+ capturedAt: 'x',
317
+ }),
318
+ );
319
+ fs.writeFileSync(
320
+ path.join(dataDir, 'token-status.json'),
321
+ JSON.stringify({ tokens: [], capturedAt: 'x' }),
322
+ );
323
+ };
324
+
325
+ const minimalProject = {
326
+ pjcode: 'um',
327
+ capturedAt: 'x',
328
+ unread: 0,
329
+ todo: 0,
330
+ qc: 0,
331
+ fail: 0,
332
+ pr: 0,
333
+ ws: 0,
334
+ dep: 0,
335
+ blocker: 0,
336
+ };
337
+
338
+ it('is true when machine, token, and every requested project file are present', () => {
339
+ const dataDir = makeDataDir();
340
+ try {
341
+ writeMachineAndToken(dataDir);
342
+ writeProject(dataDir, 'um', minimalProject);
343
+ writeProject(dataDir, 'xc', { ...minimalProject, pjcode: 'xc' });
344
+ expect(
345
+ dashboardComposeFilesPresent({
346
+ dashboardDataDir: dataDir,
347
+ projectCodes: ['um', 'xc'],
348
+ }),
349
+ ).toBe(true);
350
+ } finally {
351
+ fs.rmSync(dataDir, { recursive: true, force: true });
352
+ }
353
+ });
354
+
355
+ it('is false when no data files exist', () => {
356
+ const dataDir = makeDataDir();
357
+ try {
358
+ expect(
359
+ dashboardComposeFilesPresent({
360
+ dashboardDataDir: dataDir,
361
+ projectCodes: ['um'],
362
+ }),
363
+ ).toBe(false);
364
+ } finally {
365
+ fs.rmSync(dataDir, { recursive: true, force: true });
366
+ }
367
+ });
368
+
369
+ it('is false when a requested project file is missing', () => {
370
+ const dataDir = makeDataDir();
371
+ try {
372
+ writeMachineAndToken(dataDir);
373
+ writeProject(dataDir, 'um', minimalProject);
374
+ expect(
375
+ dashboardComposeFilesPresent({
376
+ dashboardDataDir: dataDir,
377
+ projectCodes: ['um', 'xc'],
378
+ }),
379
+ ).toBe(false);
380
+ } finally {
381
+ fs.rmSync(dataDir, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ it('is false when machine-status.json is missing', () => {
386
+ const dataDir = makeDataDir();
387
+ try {
388
+ fs.writeFileSync(
389
+ path.join(dataDir, 'token-status.json'),
390
+ JSON.stringify({ tokens: [], capturedAt: 'x' }),
391
+ );
392
+ writeProject(dataDir, 'um', minimalProject);
393
+ expect(
394
+ dashboardComposeFilesPresent({
395
+ dashboardDataDir: dataDir,
396
+ projectCodes: ['um'],
397
+ }),
398
+ ).toBe(false);
399
+ } finally {
400
+ fs.rmSync(dataDir, { recursive: true, force: true });
401
+ }
402
+ });
403
+
404
+ it('is false when token-status.json is missing', () => {
405
+ const dataDir = makeDataDir();
406
+ try {
407
+ fs.writeFileSync(
408
+ path.join(dataDir, 'machine-status.json'),
409
+ JSON.stringify({
410
+ memPct: 1,
411
+ cpuPct: 2,
412
+ load: [0, 0, 0],
413
+ cycleMinutes: null,
414
+ capturedAt: 'x',
415
+ }),
416
+ );
417
+ writeProject(dataDir, 'um', minimalProject);
418
+ expect(
419
+ dashboardComposeFilesPresent({
420
+ dashboardDataDir: dataDir,
421
+ projectCodes: ['um'],
422
+ }),
423
+ ).toBe(false);
424
+ } finally {
425
+ fs.rmSync(dataDir, { recursive: true, force: true });
426
+ }
427
+ });
428
+
429
+ it('is false when no project codes are requested', () => {
430
+ const dataDir = makeDataDir();
431
+ try {
432
+ writeMachineAndToken(dataDir);
433
+ expect(
434
+ dashboardComposeFilesPresent({
435
+ dashboardDataDir: dataDir,
436
+ projectCodes: [],
437
+ }),
438
+ ).toBe(false);
439
+ } finally {
440
+ fs.rmSync(dataDir, { recursive: true, force: true });
441
+ }
442
+ });
443
+ });
@@ -0,0 +1,200 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import {
4
+ ComposeDashboardInput,
5
+ ComposeDashboardMachineStatus,
6
+ ComposeDashboardProject,
7
+ ComposeDashboardUseCase,
8
+ } from '../../../domain/usecases/dashboard/ComposeDashboardUseCase';
9
+ import { DashboardRow } from '../../../domain/usecases/dashboard/GenerateDashboardRowUseCase';
10
+ import {
11
+ TokenStatus,
12
+ TokenStatusColor,
13
+ } from '../../../domain/usecases/dashboard/GenerateTokenStatusUseCase';
14
+
15
+ export type DashboardComposeOptions = {
16
+ dashboardDataDir: string;
17
+ projectCodes: string[];
18
+ };
19
+
20
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
21
+ value !== null && typeof value === 'object' && !Array.isArray(value);
22
+
23
+ const readJsonFile = (filePath: string): unknown => {
24
+ let raw: string;
25
+ try {
26
+ raw = fs.readFileSync(filePath, 'utf8');
27
+ } catch {
28
+ return null;
29
+ }
30
+ try {
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const asFiniteNumber = (value: unknown): number | null =>
38
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
39
+
40
+ const parseDashboardRow = (value: unknown): DashboardRow | null => {
41
+ if (!isRecord(value)) {
42
+ return null;
43
+ }
44
+ const unread = asFiniteNumber(value.unread);
45
+ const todo = asFiniteNumber(value.todo);
46
+ const qc = asFiniteNumber(value.qc);
47
+ const fail = asFiniteNumber(value.fail);
48
+ const pr = asFiniteNumber(value.pr);
49
+ const ws = asFiniteNumber(value.ws);
50
+ const dep = asFiniteNumber(value.dep);
51
+ const blocker = asFiniteNumber(value.blocker);
52
+ if (
53
+ unread === null ||
54
+ todo === null ||
55
+ qc === null ||
56
+ fail === null ||
57
+ pr === null ||
58
+ ws === null ||
59
+ dep === null ||
60
+ blocker === null
61
+ ) {
62
+ return null;
63
+ }
64
+ return { unread, todo, qc, fail, pr, ws, dep, blocker };
65
+ };
66
+
67
+ const readProjectRow = (
68
+ dashboardDataDir: string,
69
+ code: string,
70
+ ): DashboardRow | null =>
71
+ parseDashboardRow(
72
+ readJsonFile(path.join(dashboardDataDir, 'projects', `${code}.json`)),
73
+ );
74
+
75
+ const parseLoad = (value: unknown): [number, number, number] | null => {
76
+ if (!Array.isArray(value) || value.length !== 3) {
77
+ return null;
78
+ }
79
+ const oneMinute = asFiniteNumber(value[0]);
80
+ const fiveMinute = asFiniteNumber(value[1]);
81
+ const fifteenMinute = asFiniteNumber(value[2]);
82
+ if (oneMinute === null || fiveMinute === null || fifteenMinute === null) {
83
+ return null;
84
+ }
85
+ return [oneMinute, fiveMinute, fifteenMinute];
86
+ };
87
+
88
+ const readMachineStatus = (
89
+ dashboardDataDir: string,
90
+ ): ComposeDashboardMachineStatus | null => {
91
+ const value = readJsonFile(
92
+ path.join(dashboardDataDir, 'machine-status.json'),
93
+ );
94
+ if (!isRecord(value)) {
95
+ return null;
96
+ }
97
+ const cycleMinutesRaw = value.cycleMinutes;
98
+ const cycleMinutes =
99
+ cycleMinutesRaw === null ? null : asFiniteNumber(cycleMinutesRaw);
100
+ return {
101
+ memPct: asFiniteNumber(value.memPct),
102
+ cpuPct: asFiniteNumber(value.cpuPct),
103
+ load: parseLoad(value.load),
104
+ cycleMinutes,
105
+ };
106
+ };
107
+
108
+ const isTokenColor = (value: unknown): value is TokenStatusColor =>
109
+ value === 'G' || value === 'Y' || value === 'K';
110
+
111
+ const asTokenColor = (value: unknown): TokenStatusColor =>
112
+ isTokenColor(value) ? value : 'Y';
113
+
114
+ const asNullableNumber = (value: unknown): number | null =>
115
+ value === null ? null : asFiniteNumber(value);
116
+
117
+ const asCount = (value: unknown): number => {
118
+ const number = asFiniteNumber(value);
119
+ return number === null ? 0 : number;
120
+ };
121
+
122
+ const parseTokenStatus = (value: unknown): TokenStatus | null => {
123
+ if (!isRecord(value) || typeof value.name !== 'string') {
124
+ return null;
125
+ }
126
+ return {
127
+ name: value.name,
128
+ fiveHourUtilizationPercent: asNullableNumber(
129
+ value.fiveHourUtilizationPercent,
130
+ ),
131
+ fiveHourResetSeconds: asNullableNumber(value.fiveHourResetSeconds),
132
+ sevenDayUtilizationPercent: asNullableNumber(
133
+ value.sevenDayUtilizationPercent,
134
+ ),
135
+ sevenDayResetSeconds: asNullableNumber(value.sevenDayResetSeconds),
136
+ color: asTokenColor(value.color),
137
+ prep: asCount(value.prep),
138
+ hum: asCount(value.hum),
139
+ };
140
+ };
141
+
142
+ const readTokenStatuses = (dashboardDataDir: string): TokenStatus[] => {
143
+ const value = readJsonFile(path.join(dashboardDataDir, 'token-status.json'));
144
+ if (!isRecord(value) || !Array.isArray(value.tokens)) {
145
+ return [];
146
+ }
147
+ const tokens: TokenStatus[] = [];
148
+ for (const entry of value.tokens) {
149
+ const token = parseTokenStatus(entry);
150
+ if (token !== null) {
151
+ tokens.push(token);
152
+ }
153
+ }
154
+ return tokens;
155
+ };
156
+
157
+ export const buildComposeDashboardInput = (
158
+ options: DashboardComposeOptions,
159
+ ): ComposeDashboardInput => {
160
+ const projects: ComposeDashboardProject[] = options.projectCodes.map(
161
+ (code) => ({
162
+ code,
163
+ row: readProjectRow(options.dashboardDataDir, code),
164
+ }),
165
+ );
166
+ return {
167
+ projects,
168
+ machineStatus: readMachineStatus(options.dashboardDataDir),
169
+ tokens: readTokenStatuses(options.dashboardDataDir),
170
+ };
171
+ };
172
+
173
+ const isExistingFile = (filePath: string): boolean => {
174
+ try {
175
+ return fs.statSync(filePath).isFile();
176
+ } catch {
177
+ return false;
178
+ }
179
+ };
180
+
181
+ export const dashboardComposeFilesPresent = (
182
+ options: DashboardComposeOptions,
183
+ ): boolean => {
184
+ if (options.projectCodes.length === 0) {
185
+ return false;
186
+ }
187
+ const requiredFiles = [
188
+ path.join(options.dashboardDataDir, 'machine-status.json'),
189
+ path.join(options.dashboardDataDir, 'token-status.json'),
190
+ ...options.projectCodes.map((code) =>
191
+ path.join(options.dashboardDataDir, 'projects', `${code}.json`),
192
+ ),
193
+ ];
194
+ return requiredFiles.every((filePath) => isExistingFile(filePath));
195
+ };
196
+
197
+ export const composeDashboardText = (
198
+ options: DashboardComposeOptions,
199
+ ): string =>
200
+ new ComposeDashboardUseCase().run(buildComposeDashboardInput(options));
@@ -371,6 +371,8 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
371
371
  issueTitleStateCache: new IssueTitleStateCache(),
372
372
  inTmuxDataDir: null,
373
373
  dashboardDir: null,
374
+ dashboardDataDir: null,
375
+ dashboardProjectCodes: [],
374
376
  port: 0,
375
377
  });
376
378