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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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 }} · {{ 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>
|
lemming/web/logs.test.js
ADDED
|
@@ -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
|
+
});
|