lemming-cli 0.1.0__py3-none-any.whl

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 (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/web/logs.html ADDED
@@ -0,0 +1,358 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Lemming · Task Log</title>
7
+ <link
8
+ rel="icon"
9
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐹</text></svg>"
10
+ />
11
+ <style id="mancha-cloak">
12
+ html {
13
+ background-color: #f9fafb; /* Gray 50 */
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ html {
17
+ background-color: #111827; /* Gray 900 for logs */
18
+ }
19
+ }
20
+ body {
21
+ opacity: 0 !important;
22
+ }
23
+ </style>
24
+ <style>
25
+ /* Loading indicator visible during cloaking */
26
+ html.mancha-loading::after {
27
+ content: 'Loading Logs...';
28
+ position: fixed;
29
+ inset: 0;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ font-family: ui-sans-serif, system-ui, sans-serif;
34
+ font-size: 1.25rem;
35
+ font-weight: 600;
36
+ color: #4f46e5; /* Indigo 600 */
37
+ z-index: 9999;
38
+ animation: mancha-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
39
+ }
40
+
41
+ @media (prefers-color-scheme: dark) {
42
+ html.mancha-loading::after {
43
+ color: #818cf8; /* Indigo 400 */
44
+ }
45
+ }
46
+
47
+ @keyframes mancha-pulse {
48
+ 0%,
49
+ 100% {
50
+ opacity: 1;
51
+ }
52
+ 50% {
53
+ opacity: 0.5;
54
+ }
55
+ }
56
+ </style>
57
+ <script src="/static/mancha.js" css="utils"></script>
58
+ <script src="/static/favicon.js"></script>
59
+ </head>
60
+ <body
61
+ class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans h-screen overflow-hidden"
62
+ >
63
+ <div class="w-full p-4 h-full flex flex-col">
64
+ <div :show="loading" class="flex-grow flex items-center justify-center">
65
+ <span class="text-gray-400 text-sm animate-pulse">Loading...</span>
66
+ </div>
67
+ <header
68
+ :show="!loading"
69
+ class="mb-4 flex items-center justify-between gap-4 shrink-0"
70
+ >
71
+ <div class="flex items-center gap-4 shrink-0">
72
+ <a
73
+ :attr:href="'/' + ($$project ? '?project=' + encodeURIComponent($$project) : '')"
74
+ class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition duration-200"
75
+ title="Back to Dashboard"
76
+ aria-label="Back to Dashboard"
77
+ >
78
+ <svg
79
+ xmlns="http://www.w3.org/2000/svg"
80
+ class="h-6 w-6"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ stroke="currentColor"
84
+ >
85
+ <path
86
+ stroke-linecap="round"
87
+ stroke-linejoin="round"
88
+ stroke-width="2"
89
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
90
+ />
91
+ </svg>
92
+ </a>
93
+ <h1
94
+ class="text-2xl font-bold text-indigo-600 dark:text-indigo-400"
95
+ title="Lemming Task Runner"
96
+ >
97
+ Task Log
98
+ </h1>
99
+ </div>
100
+ <div
101
+ class="text-sm font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded truncate min-w-0"
102
+ :attr:title="task.description"
103
+ >
104
+ {{ task.id }} &middot; {{ task.status === 'in_progress' &&
105
+ task.requested_status ? 'finalizing' : task.status }}
106
+ </div>
107
+ </header>
108
+
109
+ <section
110
+ :show="!loading"
111
+ class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col grow min-h-0"
112
+ >
113
+ <div
114
+ class="py-2 px-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex justify-between items-center shrink-0"
115
+ >
116
+ <h2
117
+ class="text-xs font-semibold text-gray-500 uppercase tracking-wider"
118
+ >
119
+ Execution Log
120
+ </h2>
121
+ <div class="flex items-center gap-4">
122
+ <div
123
+ :if="task.status === 'in_progress' && task.started_at"
124
+ class="hidden sm:block"
125
+ >
126
+ <span class="text-10px font-semibold uppercase text-gray-400 mr-1"
127
+ >Started:</span
128
+ >
129
+ <span class="text-10px text-gray-600 dark:text-gray-400 font-mono"
130
+ >{{ formatDate(task.started_at) }}</span
131
+ >
132
+ </div>
133
+ <div
134
+ :if="task.status === 'completed' && task.completed_at"
135
+ class="hidden sm:block"
136
+ >
137
+ <span class="text-10px font-semibold uppercase text-gray-400 mr-1"
138
+ >Completed:</span
139
+ >
140
+ <span class="text-10px text-gray-600 dark:text-gray-400 font-mono"
141
+ >{{ formatDate(task.completed_at) }}</span
142
+ >
143
+ </div>
144
+ <div
145
+ :if="task.run_time || task.status === 'in_progress'"
146
+ class="hidden sm:block"
147
+ >
148
+ <span class="text-10px font-semibold uppercase text-gray-400 mr-1"
149
+ >Runtime:</span
150
+ >
151
+ <span class="text-10px text-gray-600 dark:text-gray-400 font-mono"
152
+ >{{ formatTaskRunTime(task) }}</span
153
+ >
154
+ </div>
155
+ <div
156
+ :if="task.status === 'in_progress'"
157
+ class="flex items-center gap-2"
158
+ >
159
+ <span class="relative flex h-2 w-2">
160
+ <span
161
+ :attr:class="'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ' + (task.requested_status ? 'bg-indigo-400' : 'bg-blue-400')"
162
+ ></span>
163
+ <span
164
+ :attr:class="'relative inline-flex rounded-full h-2 w-2 ' + (task.requested_status ? 'bg-indigo-500' : 'bg-blue-500')"
165
+ ></span>
166
+ </span>
167
+ <span
168
+ :attr:class="'text-xs font-medium uppercase tracking-wider ' + (task.requested_status ? 'text-indigo-600' : 'text-blue-600')"
169
+ >{{ task.requested_status ? 'Finalizing' : 'Live' }}</span
170
+ >
171
+ </div>
172
+ </div>
173
+ </div>
174
+ <div class="p-4 bg-gray-900 grow overflow-hidden flex flex-col">
175
+ <pre
176
+ id="log-container"
177
+ class="text-gray-100 text-[11px] font-mono leading-tight overflow-auto whitespace-pre-wrap grow"
178
+ :text="taskLog || 'Loading log content...'"
179
+ ></pre>
180
+ </div>
181
+ </section>
182
+
183
+ <footer :show="!loading" class="mt-4 text-center shrink-0">
184
+ <p class="text-xs text-gray-400 font-mono italic">
185
+ {{ task.description }}
186
+ </p>
187
+ </footer>
188
+ </div>
189
+
190
+ <script>
191
+ (async () => {
192
+ window.ManchaApp = Mancha.initMancha({
193
+ cloak: true,
194
+ callback: async (renderer) => {
195
+ const { $ } = renderer;
196
+
197
+ $.task = { id: '', status: '', description: '' };
198
+ $.taskLog = '';
199
+ $.loading = true;
200
+
201
+ // --- Project Scoping ---
202
+ // $$project is auto-synced with the ?project= URL query param by mancha.
203
+ $.$$project = $.$$project ?? '';
204
+
205
+ // --- Persistence Management (scoped by project) ---
206
+ const Storage = {
207
+ getPrefix() {
208
+ return `lemming[${$.$$project}]_`;
209
+ },
210
+ get(key, fallback) {
211
+ try {
212
+ const val = localStorage.getItem(this.getPrefix() + key);
213
+ return val !== null ? JSON.parse(val) : fallback;
214
+ } catch {
215
+ return fallback;
216
+ }
217
+ },
218
+ set(key, val) {
219
+ localStorage.setItem(
220
+ this.getPrefix() + key,
221
+ JSON.stringify(val),
222
+ );
223
+ },
224
+ };
225
+
226
+ $.faviconState = 'idle';
227
+ $.lastSeenState = null; // Hydrate after mount
228
+
229
+ const getTaskId = () => {
230
+ const parts = window.location.pathname.split('/');
231
+ // Expected: /tasks/{task_id}/log
232
+ return parts[parts.length - 2];
233
+ };
234
+
235
+ function logApiUrl(path) {
236
+ const url = new URL(path, window.location.origin);
237
+ if ($.$$project) url.searchParams.set('project', $.$$project);
238
+ return url.toString();
239
+ }
240
+
241
+ // --- Utilities ---
242
+ $.formatDate = (ts) =>
243
+ ts ? new Date(ts * 1000).toLocaleString() : '';
244
+ $.formatDuration = (seconds) => {
245
+ if (!seconds) return '0s';
246
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
247
+ const minutes = Math.floor(seconds / 60);
248
+ const remainingSeconds = Math.floor(seconds % 60);
249
+ return `${minutes}m ${remainingSeconds}s`;
250
+ };
251
+ $.formatTaskRunTime = (task) => {
252
+ let total = task.run_time || 0;
253
+ if (task.status === 'in_progress' && task.last_started_at) {
254
+ total += Date.now() / 1000 - task.last_started_at;
255
+ }
256
+ return $.formatDuration(total);
257
+ };
258
+
259
+ $.fetchTask = async () => {
260
+ const taskId = getTaskId();
261
+ const response = await fetch(logApiUrl(`/api/tasks/${taskId}`));
262
+ if (response.ok) {
263
+ $.task = await response.json();
264
+
265
+ // Update title: Append the top-most folder from the project path.
266
+ const project = $.$$project || '';
267
+ const folderName = project.split('/').filter(Boolean)[0];
268
+ if (folderName) {
269
+ document.title = `Lemming · Task Log · ${folderName}`;
270
+ } else {
271
+ document.title = 'Lemming · Task Log';
272
+ }
273
+
274
+ // Update favicon status
275
+ if (window.updateFavicon) {
276
+ const state =
277
+ $.task.status === 'in_progress'
278
+ ? 'running'
279
+ : $.task.status === 'completed'
280
+ ? 'success'
281
+ : $.task.attempts > 0
282
+ ? 'error'
283
+ : 'idle';
284
+
285
+ $.faviconState = state;
286
+
287
+ // If a run starts, reset the last seen state
288
+ if (state === 'running') {
289
+ $.lastSeenState = null;
290
+ Storage.set('last_seen_state', null);
291
+ }
292
+
293
+ // If the current terminal state has already been seen by the user, show 'idle' favicon instead.
294
+ const effectiveState =
295
+ (state === 'success' || state === 'error') &&
296
+ state === $.lastSeenState
297
+ ? 'idle'
298
+ : state;
299
+ window.updateFavicon(effectiveState);
300
+ }
301
+ } else {
302
+ console.error('Failed to fetch task details');
303
+ }
304
+ };
305
+
306
+ $.fetchLog = async () => {
307
+ const taskId = getTaskId();
308
+ const logUrl = logApiUrl(`/api/tasks/${taskId}/log`);
309
+ const response = await fetch(logUrl);
310
+ if (response.ok) {
311
+ const data = await response.json();
312
+ const newLog = data.log || 'No log available yet.';
313
+
314
+ // Only update if changed to avoid unnecessary re-renders
315
+ if (newLog !== $.taskLog) {
316
+ $.taskLog = newLog;
317
+ // Scroll to bottom on update
318
+ setTimeout(() => {
319
+ const container = document.getElementById('log-container');
320
+ if (container) container.scrollTop = container.scrollHeight;
321
+ }, 0);
322
+ }
323
+ }
324
+ };
325
+
326
+ $.refresh = async () => {
327
+ await $.fetchTask();
328
+ await $.fetchLog();
329
+ $.loading = false;
330
+ };
331
+
332
+ document.addEventListener('visibilitychange', () => {
333
+ if (document.visibilityState === 'visible') {
334
+ // Force an immediate refresh when returning to the tab.
335
+ $.refresh();
336
+
337
+ const state = $.faviconState;
338
+ if (state === 'success' || state === 'error') {
339
+ $.lastSeenState = state;
340
+ Storage.set('last_seen_state', state);
341
+ if (window.updateFavicon) window.updateFavicon('idle');
342
+ }
343
+ }
344
+ });
345
+
346
+ await renderer.mount(document.body);
347
+
348
+ // Hydrate persistence after mount
349
+ $.lastSeenState = Storage.get('last_seen_state', null);
350
+
351
+ await $.refresh();
352
+ setInterval($.refresh, 1000);
353
+ },
354
+ });
355
+ })();
356
+ </script>
357
+ </body>
358
+ </html>
@@ -0,0 +1,195 @@
1
+ import assert from 'node:assert';
2
+ import * as path from 'node:path';
3
+ import { describe, test } from 'node:test';
4
+ import { Renderer } from 'mancha';
5
+
6
+ const logsHtmlPath = path.join(process.cwd(), 'src/lemming/web/logs.html');
7
+
8
+ const createInitialState = (overrides = {}) => {
9
+ return {
10
+ task: { id: 'test-id', status: 'pending', description: 'Test Description' },
11
+ taskLog: '',
12
+ loading: true,
13
+ formatDate: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : ''),
14
+ formatDuration: (seconds) => {
15
+ if (!seconds) return '0s';
16
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
17
+ const minutes = Math.floor(seconds / 60);
18
+ const remainingSeconds = Math.floor(seconds % 60);
19
+ return `${minutes}m ${remainingSeconds}s`;
20
+ },
21
+ formatTaskRunTime: function (task) {
22
+ let total = task.run_time || 0;
23
+ if (task.status === 'in_progress' && task.last_started_at) {
24
+ total += Date.now() / 1000 - task.last_started_at;
25
+ }
26
+ return this.formatDuration(total);
27
+ },
28
+ ...overrides,
29
+ };
30
+ };
31
+
32
+ describe('Lemming Task Log Viewer', () => {
33
+ test('renders log content correctly', async () => {
34
+ const logContent = 'This is a test log content.\nLine 2 of logs.';
35
+ const renderer = new Renderer(
36
+ createInitialState({
37
+ task: { id: 't1', status: 'completed', description: 'Completed task' },
38
+ taskLog: logContent,
39
+ loading: false,
40
+ }),
41
+ );
42
+
43
+ const fragment = await renderer.preprocessLocal(logsHtmlPath);
44
+
45
+ // Ensure :data on body doesn't overwrite our test state
46
+ const root =
47
+ fragment.querySelector('body') || fragment.firstElementChild || fragment;
48
+ if (root.hasAttribute(':data')) root.removeAttribute(':data');
49
+ if (root.hasAttribute(':render')) root.removeAttribute(':render');
50
+
51
+ await renderer.mount(fragment);
52
+
53
+ const logContainer = fragment.querySelector('#log-container');
54
+ assert.ok(logContainer, 'Log container should exist');
55
+ assert.strictEqual(logContainer.textContent, logContent);
56
+
57
+ // Verify it doesn't contain the minified function string
58
+ assert.ok(
59
+ !logContainer.textContent.includes('(...t)=>e.call(n,...t)'),
60
+ 'Should not contain minified function string',
61
+ );
62
+ });
63
+
64
+ test('shows loading message when taskLog is empty', async () => {
65
+ const renderer = new Renderer(
66
+ createInitialState({
67
+ taskLog: '',
68
+ }),
69
+ );
70
+
71
+ const fragment = await renderer.preprocessLocal(logsHtmlPath);
72
+ const root =
73
+ fragment.querySelector('body') || fragment.firstElementChild || fragment;
74
+ if (root.hasAttribute(':data')) root.removeAttribute(':data');
75
+ await renderer.mount(fragment);
76
+
77
+ const logContainer = fragment.querySelector('#log-container');
78
+ assert.strictEqual(
79
+ logContainer.textContent.trim(),
80
+ 'Loading log content...',
81
+ );
82
+ });
83
+
84
+ test('shows live indicator for in_progress tasks', async () => {
85
+ const renderer = new Renderer(
86
+ createInitialState({
87
+ task: { id: 't1', status: 'in_progress', description: 'Running task' },
88
+ loading: false,
89
+ }),
90
+ );
91
+
92
+ const fragment = await renderer.preprocessLocal(logsHtmlPath);
93
+ const root =
94
+ fragment.querySelector('body') || fragment.firstElementChild || fragment;
95
+ if (root.hasAttribute(':data')) root.removeAttribute(':data');
96
+ await renderer.mount(fragment);
97
+
98
+ const liveIndicator = fragment.querySelector('.text-blue-600');
99
+ assert.ok(liveIndicator, 'Live indicator should be present');
100
+ assert.strictEqual(liveIndicator.textContent.trim(), 'Live');
101
+ });
102
+
103
+ test('displays timing information correctly', async () => {
104
+ const now = Date.now() / 1000;
105
+ const taskInProgress = {
106
+ id: 't-progress',
107
+ status: 'in_progress',
108
+ description: 'Running task',
109
+ started_at: now - 3600, // 1 hour ago
110
+ last_started_at: now - 600, // 10 mins ago (current attempt)
111
+ run_time: 300,
112
+ };
113
+
114
+ const renderer = new Renderer(
115
+ createInitialState({
116
+ task: taskInProgress,
117
+ loading: false,
118
+ }),
119
+ );
120
+
121
+ const fragment = await renderer.preprocessLocal(logsHtmlPath);
122
+ const root =
123
+ fragment.querySelector('body') || fragment.firstElementChild || fragment;
124
+ if (root.hasAttribute(':data')) root.removeAttribute(':data');
125
+ await renderer.mount(fragment);
126
+
127
+ const startedLabel = Array.from(fragment.querySelectorAll('span')).find(
128
+ (s) => s.textContent.trim() === 'Started:',
129
+ );
130
+ assert.ok(startedLabel, 'Started label should be present');
131
+ assert.ok(startedLabel.nextElementSibling.textContent.trim().length > 0);
132
+
133
+ const runtimeLabel = Array.from(fragment.querySelectorAll('span')).find(
134
+ (s) => s.textContent.trim() === 'Runtime:',
135
+ );
136
+ assert.ok(runtimeLabel, 'Runtime label should be present');
137
+ // 300s baseline + 600s elapsed = 900s = 15m
138
+ assert.ok(runtimeLabel.nextElementSibling.textContent.includes('15m 0s'));
139
+
140
+ // Verify completed label is NOT present for in_progress
141
+ const completedLabel = Array.from(fragment.querySelectorAll('span')).find(
142
+ (s) => s.textContent.trim() === 'Completed:',
143
+ );
144
+ assert.ok(
145
+ !completedLabel || completedLabel.parentElement.style.display === 'none',
146
+ 'Completed label should be hidden for in_progress',
147
+ );
148
+
149
+ // Test completed task
150
+ const taskCompleted = {
151
+ id: 't-completed',
152
+ status: 'completed',
153
+ description: 'Finished task',
154
+ completed_at: now - 300,
155
+ run_time: 450,
156
+ };
157
+
158
+ const renderer2 = new Renderer(
159
+ createInitialState({
160
+ task: taskCompleted,
161
+ loading: false,
162
+ }),
163
+ );
164
+
165
+ const fragment2 = await renderer2.preprocessLocal(logsHtmlPath);
166
+ const root2 =
167
+ fragment2.querySelector('body') ||
168
+ fragment2.firstElementChild ||
169
+ fragment2;
170
+ if (root2.hasAttribute(':data')) root2.removeAttribute(':data');
171
+ await renderer2.mount(fragment2);
172
+
173
+ const completedLabel2 = Array.from(fragment2.querySelectorAll('span')).find(
174
+ (s) => s.textContent.trim() === 'Completed:',
175
+ );
176
+ assert.ok(completedLabel2, 'Completed label should be present');
177
+ assert.ok(completedLabel2.nextElementSibling.textContent.trim().length > 0);
178
+
179
+ const runtimeLabel2 = Array.from(fragment2.querySelectorAll('span')).find(
180
+ (s) => s.textContent.trim() === 'Runtime:',
181
+ );
182
+ assert.ok(runtimeLabel2, 'Runtime label should be present');
183
+ // 450s = 7m 30s
184
+ assert.ok(runtimeLabel2.nextElementSibling.textContent.includes('7m 30s'));
185
+
186
+ // Verify started label is NOT present for completed
187
+ const startedLabel2 = Array.from(fragment2.querySelectorAll('span')).find(
188
+ (s) => s.textContent.trim() === 'Started:',
189
+ );
190
+ assert.ok(
191
+ !startedLabel2 || startedLabel2.parentElement.style.display === 'none',
192
+ 'Started label should be hidden for completed',
193
+ );
194
+ });
195
+ });