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
|
@@ -0,0 +1,998 @@
|
|
|
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 indexHtmlPath = path.join(process.cwd(), 'src/lemming/web/index.html');
|
|
7
|
+
|
|
8
|
+
const createInitialState = (overrides = {}) => {
|
|
9
|
+
const tasks = overrides.tasks || [];
|
|
10
|
+
const hideCompleted = overrides.hideCompleted || false;
|
|
11
|
+
return {
|
|
12
|
+
tasks,
|
|
13
|
+
context: '',
|
|
14
|
+
cwd: '/test/cwd',
|
|
15
|
+
newTask: '',
|
|
16
|
+
loading: true,
|
|
17
|
+
agents: [],
|
|
18
|
+
selectedAgent: 'agy',
|
|
19
|
+
envOverrides: [],
|
|
20
|
+
runners: [],
|
|
21
|
+
availableHooks: [],
|
|
22
|
+
config: {
|
|
23
|
+
retries: 3,
|
|
24
|
+
runner: 'agy',
|
|
25
|
+
hooks: ['roadmap'],
|
|
26
|
+
},
|
|
27
|
+
folderPickerBreadcrumbs: [],
|
|
28
|
+
folderPickerDirs: [],
|
|
29
|
+
hideCompleted,
|
|
30
|
+
toasts: [],
|
|
31
|
+
expanded: [],
|
|
32
|
+
timeLimit: '3600',
|
|
33
|
+
trim: (s, l = 60) =>
|
|
34
|
+
s && s.length > l ? `${s.substring(0, l - 3)}...` : s,
|
|
35
|
+
formatDate: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : ''),
|
|
36
|
+
formatDuration: (seconds) => {
|
|
37
|
+
if (!seconds) return '0s';
|
|
38
|
+
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
39
|
+
const minutes = Math.floor(seconds / 60);
|
|
40
|
+
const remainingSeconds = Math.floor(seconds % 60);
|
|
41
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
42
|
+
},
|
|
43
|
+
formatTaskRunTime: function (task) {
|
|
44
|
+
let total = task.run_time || 0;
|
|
45
|
+
if (task.status === 'in_progress' && task.last_started_at) {
|
|
46
|
+
total += Date.now() / 1000 - task.last_started_at;
|
|
47
|
+
}
|
|
48
|
+
return this.formatDuration(total);
|
|
49
|
+
},
|
|
50
|
+
filteredTasks: $computed(() => {
|
|
51
|
+
const ts = [...tasks];
|
|
52
|
+
ts.sort((a, b) => {
|
|
53
|
+
const aDone = a.status === 'completed' || a.status === 'failed' ? 1 : 0;
|
|
54
|
+
const bDone = b.status === 'completed' || b.status === 'failed' ? 1 : 0;
|
|
55
|
+
if (aDone !== bDone) return aDone - bDone;
|
|
56
|
+
|
|
57
|
+
if (!aDone) {
|
|
58
|
+
const aInProgress = a.status === 'in_progress' ? 0 : 1;
|
|
59
|
+
const bInProgress = b.status === 'in_progress' ? 0 : 1;
|
|
60
|
+
if (aInProgress !== bInProgress) return aInProgress - bInProgress;
|
|
61
|
+
|
|
62
|
+
if (a.index !== b.index) return (a.index || 0) - (b.index || 0);
|
|
63
|
+
return (a.created_at || 0) - (b.created_at || 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const aTime = a.completed_at || a.created_at || 0;
|
|
67
|
+
const bTime = b.completed_at || b.created_at || 0;
|
|
68
|
+
if (aTime !== bTime) return bTime - aTime;
|
|
69
|
+
|
|
70
|
+
return (b.index || 0) - (a.index || 0);
|
|
71
|
+
});
|
|
72
|
+
return ts.filter(
|
|
73
|
+
(t) =>
|
|
74
|
+
(t.status !== 'completed' && t.status !== 'failed') || !hideCompleted,
|
|
75
|
+
);
|
|
76
|
+
}),
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const $computed = (fn) => fn();
|
|
82
|
+
|
|
83
|
+
describe('Lemming Web Dashboard', () => {
|
|
84
|
+
test('tasks are sorted correctly in frontend', async () => {
|
|
85
|
+
// Provide tasks in a "jumbled" order to test if the frontend sorts them.
|
|
86
|
+
const tasks = [
|
|
87
|
+
{
|
|
88
|
+
id: 'c1',
|
|
89
|
+
description: 'Oldest Completed',
|
|
90
|
+
status: 'completed',
|
|
91
|
+
completed_at: 1000,
|
|
92
|
+
progress: [],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'p1',
|
|
96
|
+
description: 'Pending 1',
|
|
97
|
+
status: 'pending',
|
|
98
|
+
created_at: 500,
|
|
99
|
+
progress: [],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'r1',
|
|
103
|
+
description: 'Running',
|
|
104
|
+
status: 'in_progress',
|
|
105
|
+
created_at: 1500,
|
|
106
|
+
progress: [],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'c2',
|
|
110
|
+
description: 'Newest Completed',
|
|
111
|
+
status: 'completed',
|
|
112
|
+
completed_at: 2000,
|
|
113
|
+
progress: [],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'p2',
|
|
117
|
+
description: 'Pending 2',
|
|
118
|
+
status: 'pending',
|
|
119
|
+
created_at: 3000,
|
|
120
|
+
progress: [],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const renderer = new Renderer(
|
|
125
|
+
createInitialState({
|
|
126
|
+
tasks,
|
|
127
|
+
loading: false,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
132
|
+
await renderer.mount(fragment);
|
|
133
|
+
|
|
134
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
135
|
+
assert.strictEqual(taskItems.length, 5);
|
|
136
|
+
|
|
137
|
+
const descriptions = Array.from(taskItems).map((item) => {
|
|
138
|
+
const p = item.querySelector('p');
|
|
139
|
+
return p ? p.textContent.trim() : '';
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Check they are sorted:
|
|
143
|
+
// 1. Running (r1, in_progress)
|
|
144
|
+
// 2. Pending 1 (p1, created_at 500)
|
|
145
|
+
// 3. Pending 2 (p2, created_at 3000)
|
|
146
|
+
// 4. Newest Completed (c2, completed_at 2000)
|
|
147
|
+
// 5. Oldest Completed (c1, completed_at 1000)
|
|
148
|
+
assert.strictEqual(descriptions[0], 'Running');
|
|
149
|
+
assert.strictEqual(descriptions[1], 'Pending 1');
|
|
150
|
+
assert.strictEqual(descriptions[2], 'Pending 2');
|
|
151
|
+
assert.strictEqual(descriptions[3], 'Newest Completed');
|
|
152
|
+
assert.strictEqual(descriptions[4], 'Oldest Completed');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('tasks sorting tie-breaker in frontend', async () => {
|
|
156
|
+
// Tasks with same timestamps but different indices
|
|
157
|
+
const tasks = [
|
|
158
|
+
{
|
|
159
|
+
id: 'a',
|
|
160
|
+
description: 'Task A',
|
|
161
|
+
status: 'pending',
|
|
162
|
+
created_at: 1000,
|
|
163
|
+
index: 0,
|
|
164
|
+
progress: [],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'b',
|
|
168
|
+
description: 'Task B',
|
|
169
|
+
status: 'pending',
|
|
170
|
+
created_at: 1000,
|
|
171
|
+
index: 1,
|
|
172
|
+
progress: [],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'c',
|
|
176
|
+
description: 'Task C',
|
|
177
|
+
status: 'pending',
|
|
178
|
+
created_at: 1000,
|
|
179
|
+
index: 2,
|
|
180
|
+
progress: [],
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const renderer = new Renderer(
|
|
185
|
+
createInitialState({
|
|
186
|
+
tasks,
|
|
187
|
+
loading: false,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
192
|
+
await renderer.mount(fragment);
|
|
193
|
+
|
|
194
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
195
|
+
const descriptions = Array.from(taskItems).map((item) => {
|
|
196
|
+
const p = item.querySelector('p');
|
|
197
|
+
return p ? p.textContent.trim() : '';
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Should be A, B, C (FIFO by index)
|
|
201
|
+
assert.strictEqual(descriptions[0], 'Task A');
|
|
202
|
+
assert.strictEqual(descriptions[1], 'Task B');
|
|
203
|
+
assert.strictEqual(descriptions[2], 'Task C');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('initial state', async () => {
|
|
207
|
+
const renderer = new Renderer(createInitialState({ loading: false }));
|
|
208
|
+
|
|
209
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
210
|
+
|
|
211
|
+
// Ensure :data on body doesn't overwrite our test state
|
|
212
|
+
const root =
|
|
213
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
214
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
215
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
216
|
+
|
|
217
|
+
await renderer.mount(fragment);
|
|
218
|
+
|
|
219
|
+
const heading = fragment.querySelector('h1');
|
|
220
|
+
assert.strictEqual(heading.textContent.trim(), 'Lemming Task Runner');
|
|
221
|
+
|
|
222
|
+
const noTasks = fragment.querySelector('[role="status"]');
|
|
223
|
+
assert.ok(noTasks, "Should show 'No tasks yet'");
|
|
224
|
+
assert.ok(noTasks.textContent.includes('No tasks yet'));
|
|
225
|
+
|
|
226
|
+
// Find the "Project Directory" label and verify the path display and file browser link.
|
|
227
|
+
const labels = Array.from(fragment.querySelectorAll('label'));
|
|
228
|
+
const cwdLabel = labels.find((l) =>
|
|
229
|
+
l.textContent.includes('Project Directory'),
|
|
230
|
+
);
|
|
231
|
+
assert.ok(cwdLabel, 'Project Directory label should exist');
|
|
232
|
+
const filesLink = cwdLabel
|
|
233
|
+
.closest('div')
|
|
234
|
+
.querySelector('a[title="Browse files"]');
|
|
235
|
+
assert.ok(filesLink, 'Browse files link should exist');
|
|
236
|
+
assert.strictEqual(filesLink.getAttribute('href'), '/files/');
|
|
237
|
+
assert.strictEqual(filesLink.getAttribute('target'), '_blank');
|
|
238
|
+
const cwdSpan = cwdLabel
|
|
239
|
+
.closest('div')
|
|
240
|
+
.parentElement.querySelector('span.font-mono');
|
|
241
|
+
assert.ok(cwdSpan, 'CWD span should exist');
|
|
242
|
+
assert.strictEqual(cwdSpan.textContent.trim(), '/test/cwd');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('renders tasks correctly', async () => {
|
|
246
|
+
const tasks = [
|
|
247
|
+
{
|
|
248
|
+
id: '123',
|
|
249
|
+
description: 'Test Task 1',
|
|
250
|
+
status: 'pending',
|
|
251
|
+
attempts: 0,
|
|
252
|
+
progress: [],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: '456',
|
|
256
|
+
description: 'Test Task 2',
|
|
257
|
+
status: 'completed',
|
|
258
|
+
attempts: 1,
|
|
259
|
+
progress: ['Success'],
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const renderer = new Renderer(
|
|
264
|
+
createInitialState({
|
|
265
|
+
tasks,
|
|
266
|
+
context: 'Test context',
|
|
267
|
+
loading: false,
|
|
268
|
+
filteredTasks: tasks,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
273
|
+
|
|
274
|
+
// Ensure :data on body doesn't overwrite our test state
|
|
275
|
+
const root =
|
|
276
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
277
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
278
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
279
|
+
|
|
280
|
+
await renderer.mount(fragment);
|
|
281
|
+
|
|
282
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
283
|
+
assert.strictEqual(taskItems.length, 2);
|
|
284
|
+
|
|
285
|
+
const descriptions = Array.from(taskItems).map((item) => {
|
|
286
|
+
const p = item.querySelector('p');
|
|
287
|
+
return p ? p.textContent.trim() : '';
|
|
288
|
+
});
|
|
289
|
+
assert.ok(descriptions.includes('Test Task 1'));
|
|
290
|
+
assert.ok(descriptions.includes('Test Task 2'));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('hides completed tasks when hideCompleted is true', async () => {
|
|
294
|
+
const tasks = [
|
|
295
|
+
{
|
|
296
|
+
id: '123',
|
|
297
|
+
description: 'Pending Task',
|
|
298
|
+
status: 'pending',
|
|
299
|
+
attempts: 0,
|
|
300
|
+
progress: [],
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: '456',
|
|
304
|
+
description: 'Completed Task',
|
|
305
|
+
status: 'completed',
|
|
306
|
+
attempts: 1,
|
|
307
|
+
progress: ['Success'],
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const renderer = new Renderer(
|
|
312
|
+
createInitialState({
|
|
313
|
+
tasks,
|
|
314
|
+
hideCompleted: true,
|
|
315
|
+
loading: false,
|
|
316
|
+
filteredTasks: tasks.filter((t) => t.status !== 'completed'),
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
321
|
+
|
|
322
|
+
const root =
|
|
323
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
324
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
325
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
326
|
+
|
|
327
|
+
await renderer.mount(fragment);
|
|
328
|
+
|
|
329
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
330
|
+
assert.strictEqual(taskItems.length, 1);
|
|
331
|
+
assert.ok(taskItems[0].textContent.includes('Pending Task'));
|
|
332
|
+
assert.ok(!taskItems[0].textContent.includes('Completed Task'));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('shows only stop button for in_progress tasks', async () => {
|
|
336
|
+
const tasks = [
|
|
337
|
+
{
|
|
338
|
+
id: '123',
|
|
339
|
+
description: 'Running Task',
|
|
340
|
+
status: 'in_progress',
|
|
341
|
+
attempts: 1,
|
|
342
|
+
progress: [],
|
|
343
|
+
},
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const renderer = new Renderer(
|
|
347
|
+
createInitialState({
|
|
348
|
+
tasks,
|
|
349
|
+
loading: false,
|
|
350
|
+
filteredTasks: tasks,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
355
|
+
|
|
356
|
+
const root =
|
|
357
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
358
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
359
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
360
|
+
|
|
361
|
+
await renderer.mount(fragment);
|
|
362
|
+
|
|
363
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
364
|
+
|
|
365
|
+
const expandButton = taskItem.querySelector('[aria-label="Show details"]');
|
|
366
|
+
const taskActionsButton = taskItem.querySelector(
|
|
367
|
+
'[aria-label="Task Actions"]',
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
assert.ok(expandButton, 'Expand button should be present');
|
|
371
|
+
assert.strictEqual(
|
|
372
|
+
expandButton.style.display,
|
|
373
|
+
'',
|
|
374
|
+
'Expand button should be visible',
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
assert.ok(taskActionsButton, 'Task Actions button should be present');
|
|
378
|
+
assert.strictEqual(
|
|
379
|
+
taskActionsButton.style.display,
|
|
380
|
+
'',
|
|
381
|
+
'Task Actions button should be visible',
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Inline edit and uncomplete buttons were consolidated into the task action modal
|
|
385
|
+
const editButton = taskItem.querySelector('[aria-label="Edit Task"]');
|
|
386
|
+
assert.ok(!editButton, 'Inline Edit button should not exist');
|
|
387
|
+
|
|
388
|
+
const uncompleteButton = taskItem.querySelector(
|
|
389
|
+
'[aria-label="Mark as Pending"]',
|
|
390
|
+
);
|
|
391
|
+
assert.ok(!uncompleteButton, 'Inline Uncomplete button should not exist');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('shows long descriptions in queue and full in details', async () => {
|
|
395
|
+
const longDescription =
|
|
396
|
+
'This is a very long task description that should occupy as much space as possible in the queue list but show fully when expanded and should be ellipsized properly by CSS.';
|
|
397
|
+
const tasks = [
|
|
398
|
+
{
|
|
399
|
+
id: '123',
|
|
400
|
+
description: longDescription,
|
|
401
|
+
status: 'pending',
|
|
402
|
+
attempts: 0,
|
|
403
|
+
progress: [],
|
|
404
|
+
},
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
const renderer = new Renderer(
|
|
408
|
+
createInitialState({
|
|
409
|
+
tasks,
|
|
410
|
+
loading: false,
|
|
411
|
+
expanded: { 123: true },
|
|
412
|
+
filteredTasks: tasks,
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
417
|
+
|
|
418
|
+
const root =
|
|
419
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
420
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
421
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
422
|
+
|
|
423
|
+
await renderer.mount(fragment);
|
|
424
|
+
|
|
425
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
426
|
+
const leftSide = taskItem.querySelector(
|
|
427
|
+
'.flex.items-center.gap-2.overflow-hidden',
|
|
428
|
+
);
|
|
429
|
+
assert.ok(
|
|
430
|
+
leftSide.classList.contains('flex-grow'),
|
|
431
|
+
'Left side should have flex-grow',
|
|
432
|
+
);
|
|
433
|
+
assert.ok(
|
|
434
|
+
leftSide.classList.contains('min-w-0'),
|
|
435
|
+
'Left side should have min-w-0',
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const descriptionWrapper = leftSide.querySelector(
|
|
439
|
+
'.overflow-hidden.flex-grow.min-w-0',
|
|
440
|
+
);
|
|
441
|
+
assert.ok(
|
|
442
|
+
descriptionWrapper,
|
|
443
|
+
'Description wrapper should have flex-grow and min-w-0',
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const p = descriptionWrapper.querySelector('p');
|
|
447
|
+
|
|
448
|
+
// Should now contain the full description (truncation handled by CSS)
|
|
449
|
+
assert.strictEqual(p.textContent.trim(), longDescription);
|
|
450
|
+
assert.ok(p.classList.contains('truncate'), 'Should have truncate class');
|
|
451
|
+
|
|
452
|
+
const details = taskItem.querySelector('.px-12.pb-3');
|
|
453
|
+
const fullDescriptionDiv = details.querySelector('.mt-2.text-gray-700');
|
|
454
|
+
assert.strictEqual(fullDescriptionDiv.textContent.trim(), longDescription);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('delete completed button visibility', async () => {
|
|
458
|
+
// 1. No completed tasks
|
|
459
|
+
const tasksNoCompleted = [
|
|
460
|
+
{
|
|
461
|
+
id: '1',
|
|
462
|
+
description: 'Pending',
|
|
463
|
+
status: 'pending',
|
|
464
|
+
attempts: 0,
|
|
465
|
+
progress: [],
|
|
466
|
+
},
|
|
467
|
+
];
|
|
468
|
+
let renderer = new Renderer(
|
|
469
|
+
createInitialState({
|
|
470
|
+
tasks: tasksNoCompleted,
|
|
471
|
+
loading: false,
|
|
472
|
+
runningCount: 0,
|
|
473
|
+
pendingCount: 1,
|
|
474
|
+
completedCount: 0,
|
|
475
|
+
failedCount: 0,
|
|
476
|
+
filteredTasks: tasksNoCompleted,
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
let fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
480
|
+
let root =
|
|
481
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
482
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
483
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
484
|
+
await renderer.mount(fragment);
|
|
485
|
+
|
|
486
|
+
let deleteCompletedBtn = fragment.querySelector(
|
|
487
|
+
'[aria-label="Delete all completed tasks"]',
|
|
488
|
+
);
|
|
489
|
+
assert.ok(deleteCompletedBtn, 'Delete Completed button should exist');
|
|
490
|
+
assert.strictEqual(
|
|
491
|
+
deleteCompletedBtn.style.display,
|
|
492
|
+
'none',
|
|
493
|
+
'Delete Completed button should be hidden when no completed tasks',
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// 2. With completed tasks
|
|
497
|
+
const tasksWithCompleted = [
|
|
498
|
+
{
|
|
499
|
+
id: '1',
|
|
500
|
+
description: 'Pending',
|
|
501
|
+
status: 'pending',
|
|
502
|
+
attempts: 0,
|
|
503
|
+
progress: [],
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: '2',
|
|
507
|
+
description: 'Completed',
|
|
508
|
+
status: 'completed',
|
|
509
|
+
attempts: 1,
|
|
510
|
+
progress: [],
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
renderer = new Renderer(
|
|
514
|
+
createInitialState({
|
|
515
|
+
tasks: tasksWithCompleted,
|
|
516
|
+
loading: false,
|
|
517
|
+
runningCount: 0,
|
|
518
|
+
pendingCount: 1,
|
|
519
|
+
completedCount: 1,
|
|
520
|
+
failedCount: 0,
|
|
521
|
+
filteredTasks: tasksWithCompleted,
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
525
|
+
root =
|
|
526
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
527
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
528
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
529
|
+
await renderer.mount(fragment);
|
|
530
|
+
|
|
531
|
+
deleteCompletedBtn = fragment.querySelector(
|
|
532
|
+
'[aria-label="Delete all completed tasks"]',
|
|
533
|
+
);
|
|
534
|
+
assert.ok(deleteCompletedBtn, 'Delete Completed button should exist');
|
|
535
|
+
assert.strictEqual(
|
|
536
|
+
deleteCompletedBtn.style.display,
|
|
537
|
+
'',
|
|
538
|
+
'Delete Completed button should be visible when there are completed tasks',
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('run loop button disabled when loopRunning is true', async () => {
|
|
543
|
+
const renderer = new Renderer(
|
|
544
|
+
createInitialState({
|
|
545
|
+
loopRunning: true,
|
|
546
|
+
loading: false,
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
551
|
+
const root =
|
|
552
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
553
|
+
await renderer.mount(fragment);
|
|
554
|
+
|
|
555
|
+
const runLoopBtn = fragment.querySelector('[aria-label="Execute Tasks"]');
|
|
556
|
+
assert.ok(runLoopBtn, 'Execute Tasks button should exist');
|
|
557
|
+
assert.ok(
|
|
558
|
+
runLoopBtn.hasAttribute('disabled'),
|
|
559
|
+
'Execute Tasks button should be disabled when loopRunning is true',
|
|
560
|
+
);
|
|
561
|
+
assert.strictEqual(runLoopBtn.textContent.trim(), 'Executing...');
|
|
562
|
+
assert.ok(
|
|
563
|
+
runLoopBtn.classList.contains('bg-gray-400'),
|
|
564
|
+
'Should have gray background when disabled',
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// Test enabled state
|
|
568
|
+
const renderer2 = new Renderer(
|
|
569
|
+
createInitialState({
|
|
570
|
+
loopRunning: false,
|
|
571
|
+
loading: false,
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const fragment2 = await renderer2.preprocessLocal(indexHtmlPath);
|
|
576
|
+
const root2 =
|
|
577
|
+
fragment2.querySelector('body') ||
|
|
578
|
+
fragment2.firstElementChild ||
|
|
579
|
+
fragment2;
|
|
580
|
+
if (root2.hasAttribute(':data')) root2.removeAttribute(':data');
|
|
581
|
+
await renderer2.mount(fragment2);
|
|
582
|
+
|
|
583
|
+
const runLoopBtn2 = fragment2.querySelector('[aria-label="Execute Tasks"]');
|
|
584
|
+
const isDisabled2 =
|
|
585
|
+
runLoopBtn2.hasAttribute('disabled') &&
|
|
586
|
+
runLoopBtn2.getAttribute('disabled') !== 'false';
|
|
587
|
+
assert.strictEqual(
|
|
588
|
+
isDisabled2,
|
|
589
|
+
false,
|
|
590
|
+
'Execute Tasks button should not be disabled when loopRunning is false',
|
|
591
|
+
);
|
|
592
|
+
assert.strictEqual(runLoopBtn2.textContent.trim(), 'Execute Tasks');
|
|
593
|
+
assert.ok(
|
|
594
|
+
runLoopBtn2.classList.contains('bg-indigo-600'),
|
|
595
|
+
'Should have indigo background when enabled',
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('renders progress with whitespace-pre-wrap class', async () => {
|
|
600
|
+
const initialState = createInitialState({
|
|
601
|
+
tasks: [
|
|
602
|
+
{
|
|
603
|
+
id: '123',
|
|
604
|
+
description: 'Task with progress',
|
|
605
|
+
status: 'completed',
|
|
606
|
+
progress: ['Progress 1', 'Progress 2'],
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const renderer = new Renderer(initialState);
|
|
612
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
613
|
+
|
|
614
|
+
const root =
|
|
615
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
616
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
617
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
618
|
+
|
|
619
|
+
await renderer.mount(fragment);
|
|
620
|
+
|
|
621
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
622
|
+
const progressList = taskItem.querySelector('ul');
|
|
623
|
+
const progressItems = progressList.querySelectorAll('li');
|
|
624
|
+
assert.strictEqual(progressItems.length, 2);
|
|
625
|
+
assert.ok(
|
|
626
|
+
progressItems[0].classList.contains('whitespace-pre-wrap'),
|
|
627
|
+
'First progress entry should have whitespace-pre-wrap class',
|
|
628
|
+
);
|
|
629
|
+
assert.ok(
|
|
630
|
+
progressItems[1].classList.contains('whitespace-pre-wrap'),
|
|
631
|
+
'Second progress entry should have whitespace-pre-wrap class',
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test('renders progress entries in expanded details', async () => {
|
|
636
|
+
const tasks = [
|
|
637
|
+
{
|
|
638
|
+
id: '123',
|
|
639
|
+
description: 'Task with progress',
|
|
640
|
+
status: 'pending',
|
|
641
|
+
attempts: 1,
|
|
642
|
+
progress: ['Step 1 done', 'Step 2 done'],
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
const renderer = new Renderer(
|
|
647
|
+
createInitialState({
|
|
648
|
+
tasks,
|
|
649
|
+
loading: false,
|
|
650
|
+
expanded: { 123: true },
|
|
651
|
+
filteredTasks: tasks,
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
656
|
+
const root =
|
|
657
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
658
|
+
await renderer.mount(fragment);
|
|
659
|
+
|
|
660
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
661
|
+
const progressList = taskItem.querySelector('ul');
|
|
662
|
+
assert.ok(progressList, 'Progress list should be present');
|
|
663
|
+
const progressItems = progressList.querySelectorAll('li');
|
|
664
|
+
assert.strictEqual(progressItems.length, 2);
|
|
665
|
+
assert.strictEqual(progressItems[0].textContent.trim(), 'Step 1 done');
|
|
666
|
+
assert.strictEqual(progressItems[1].textContent.trim(), 'Step 2 done');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('hides progress section when no progress is present', async () => {
|
|
670
|
+
const tasks = [
|
|
671
|
+
{
|
|
672
|
+
id: '123',
|
|
673
|
+
description: 'Task with no progress',
|
|
674
|
+
status: 'pending',
|
|
675
|
+
attempts: 0,
|
|
676
|
+
progress: [],
|
|
677
|
+
},
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
const renderer = new Renderer(
|
|
681
|
+
createInitialState({
|
|
682
|
+
tasks,
|
|
683
|
+
loading: false,
|
|
684
|
+
expanded: { 123: true },
|
|
685
|
+
filteredTasks: tasks,
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
690
|
+
const root =
|
|
691
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
692
|
+
await renderer.mount(fragment);
|
|
693
|
+
|
|
694
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
695
|
+
const progressHeader = Array.from(taskItem.querySelectorAll('span')).find(
|
|
696
|
+
(s) => s.textContent.trim() === 'Progress:',
|
|
697
|
+
);
|
|
698
|
+
if (progressHeader) {
|
|
699
|
+
const progressDiv = progressHeader.parentElement;
|
|
700
|
+
assert.strictEqual(
|
|
701
|
+
progressDiv.style.display,
|
|
702
|
+
'none',
|
|
703
|
+
'Progress section should be hidden',
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('no inline edit button for completed tasks', async () => {
|
|
709
|
+
const tasks = [
|
|
710
|
+
{
|
|
711
|
+
id: '123',
|
|
712
|
+
description: 'Completed Task',
|
|
713
|
+
status: 'completed',
|
|
714
|
+
attempts: 1,
|
|
715
|
+
progress: ['Success'],
|
|
716
|
+
},
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
const renderer = new Renderer(
|
|
720
|
+
createInitialState({
|
|
721
|
+
tasks,
|
|
722
|
+
loading: false,
|
|
723
|
+
filteredTasks: tasks,
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
728
|
+
|
|
729
|
+
const root =
|
|
730
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
731
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
732
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
733
|
+
|
|
734
|
+
await renderer.mount(fragment);
|
|
735
|
+
|
|
736
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
737
|
+
// Edit button was consolidated into the task action modal
|
|
738
|
+
const editButton = taskItem.querySelector('[aria-label="Edit Task"]');
|
|
739
|
+
assert.ok(!editButton, 'Inline Edit button should not exist');
|
|
740
|
+
|
|
741
|
+
// Task Actions button should still be present
|
|
742
|
+
const taskActionsButton = taskItem.querySelector(
|
|
743
|
+
'[aria-label="Task Actions"]',
|
|
744
|
+
);
|
|
745
|
+
assert.ok(taskActionsButton, 'Task Actions button should be present');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test('project context textarea attributes', async () => {
|
|
749
|
+
const renderer = new Renderer(
|
|
750
|
+
createInitialState({
|
|
751
|
+
context: 'Initial context',
|
|
752
|
+
loading: false,
|
|
753
|
+
}),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
757
|
+
|
|
758
|
+
const textarea = fragment.querySelector(
|
|
759
|
+
'textarea[aria-label="Project context and guidelines"]',
|
|
760
|
+
);
|
|
761
|
+
assert.ok(textarea, 'Project context textarea should exist');
|
|
762
|
+
assert.strictEqual(textarea.getAttribute(':on:input'), 'updateContext()');
|
|
763
|
+
|
|
764
|
+
const root =
|
|
765
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
766
|
+
await renderer.mount(fragment);
|
|
767
|
+
|
|
768
|
+
assert.strictEqual(textarea.value, 'Initial context');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test('status chip colors and labels', async () => {
|
|
772
|
+
const tasks = [
|
|
773
|
+
{
|
|
774
|
+
id: 'r1',
|
|
775
|
+
description: 'Running Task',
|
|
776
|
+
status: 'in_progress',
|
|
777
|
+
attempts: 1,
|
|
778
|
+
progress: [],
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
id: 'p1',
|
|
782
|
+
description: 'Pending Task',
|
|
783
|
+
status: 'pending',
|
|
784
|
+
attempts: 0,
|
|
785
|
+
progress: [],
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
id: 'f1',
|
|
789
|
+
description: 'Terminal Failed Task',
|
|
790
|
+
status: 'failed',
|
|
791
|
+
attempts: 1,
|
|
792
|
+
progress: ['Fatal error'],
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
id: 'f2',
|
|
796
|
+
description: 'Retriable Failed Task',
|
|
797
|
+
status: 'pending',
|
|
798
|
+
attempts: 1,
|
|
799
|
+
progress: ['Temporary error'],
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
id: 'c1',
|
|
803
|
+
description: 'Completed Task',
|
|
804
|
+
status: 'completed',
|
|
805
|
+
attempts: 1,
|
|
806
|
+
progress: [],
|
|
807
|
+
},
|
|
808
|
+
];
|
|
809
|
+
|
|
810
|
+
const renderer = new Renderer(
|
|
811
|
+
createInitialState({
|
|
812
|
+
tasks,
|
|
813
|
+
loading: false,
|
|
814
|
+
filteredTasks: tasks,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
819
|
+
const root =
|
|
820
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
821
|
+
await renderer.mount(fragment);
|
|
822
|
+
|
|
823
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
824
|
+
assert.strictEqual(taskItems.length, 5);
|
|
825
|
+
|
|
826
|
+
// 1. Running Task
|
|
827
|
+
const runningChip = taskItems[0].querySelector('[role="status"]');
|
|
828
|
+
assert.strictEqual(runningChip.textContent.trim(), 'Running');
|
|
829
|
+
assert.ok(runningChip.classList.contains('bg-blue-100'));
|
|
830
|
+
assert.ok(runningChip.classList.contains('text-blue-700'));
|
|
831
|
+
|
|
832
|
+
// 2. Pending Task
|
|
833
|
+
const pendingChip = taskItems[1].querySelector('[role="status"]');
|
|
834
|
+
assert.strictEqual(pendingChip.textContent.trim(), 'Pending');
|
|
835
|
+
assert.ok(pendingChip.classList.contains('bg-gray-100'));
|
|
836
|
+
assert.ok(pendingChip.classList.contains('text-gray-500'));
|
|
837
|
+
|
|
838
|
+
// 3. Terminal Failed Task
|
|
839
|
+
const failedChip = taskItems[2].querySelector('[role="status"]');
|
|
840
|
+
assert.strictEqual(failedChip.textContent.trim(), 'Failed');
|
|
841
|
+
assert.ok(failedChip.classList.contains('bg-red-100'));
|
|
842
|
+
assert.ok(failedChip.classList.contains('text-red-700'));
|
|
843
|
+
|
|
844
|
+
// 4. Retriable Failed Task (pending with attempts)
|
|
845
|
+
const retriableChip = taskItems[3].querySelector('[role="status"]');
|
|
846
|
+
assert.strictEqual(retriableChip.textContent.trim(), 'Failed');
|
|
847
|
+
assert.ok(retriableChip.classList.contains('bg-red-100'));
|
|
848
|
+
|
|
849
|
+
// 5. Completed Task
|
|
850
|
+
const completedChip = taskItems[4].querySelector('[role="status"]');
|
|
851
|
+
assert.strictEqual(completedChip.textContent.trim(), 'Completed');
|
|
852
|
+
assert.ok(completedChip.classList.contains('bg-green-100'));
|
|
853
|
+
assert.ok(completedChip.classList.contains('text-green-700'));
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('displays real-time run time for in-progress tasks', async () => {
|
|
857
|
+
const now = Date.now() / 1000;
|
|
858
|
+
const tasks = [
|
|
859
|
+
{
|
|
860
|
+
id: 'r1',
|
|
861
|
+
description: 'Running Task',
|
|
862
|
+
status: 'in_progress',
|
|
863
|
+
attempts: 1,
|
|
864
|
+
run_time: 50.0,
|
|
865
|
+
last_started_at: now - 20, // Started 20 seconds ago
|
|
866
|
+
progress: [],
|
|
867
|
+
},
|
|
868
|
+
];
|
|
869
|
+
|
|
870
|
+
const renderer = new Renderer(
|
|
871
|
+
createInitialState({
|
|
872
|
+
tasks,
|
|
873
|
+
loading: false,
|
|
874
|
+
expanded: { r1: true },
|
|
875
|
+
filteredTasks: tasks,
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
880
|
+
const root =
|
|
881
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
882
|
+
await renderer.mount(fragment);
|
|
883
|
+
|
|
884
|
+
const taskItem = fragment.querySelector('[role="listitem"]');
|
|
885
|
+
const runTimeValue = Array.from(taskItem.querySelectorAll('span')).find(
|
|
886
|
+
(s) => s.previousElementSibling?.textContent?.includes('Run Time:'),
|
|
887
|
+
);
|
|
888
|
+
assert.ok(runTimeValue, 'Run Time value should be present');
|
|
889
|
+
// Should be around 70s (50 + 20)
|
|
890
|
+
assert.ok(runTimeValue.textContent.includes('1m 10s'));
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test('displays started at for in-progress tasks and completed at for completed tasks', async () => {
|
|
894
|
+
const now = Date.now() / 1000;
|
|
895
|
+
const tasks = [
|
|
896
|
+
{
|
|
897
|
+
id: 'r1',
|
|
898
|
+
description: 'Running Task',
|
|
899
|
+
status: 'in_progress',
|
|
900
|
+
attempts: 1,
|
|
901
|
+
started_at: now - 3600, // Started 1 hour ago
|
|
902
|
+
progress: [],
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
id: 'c1',
|
|
906
|
+
description: 'Completed Task',
|
|
907
|
+
status: 'completed',
|
|
908
|
+
completed_at: now - 1800, // Completed 30 mins ago
|
|
909
|
+
progress: [],
|
|
910
|
+
},
|
|
911
|
+
];
|
|
912
|
+
|
|
913
|
+
const renderer = new Renderer(
|
|
914
|
+
createInitialState({
|
|
915
|
+
tasks,
|
|
916
|
+
loading: false,
|
|
917
|
+
expanded: { r1: true, c1: true },
|
|
918
|
+
filteredTasks: tasks,
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
923
|
+
const root =
|
|
924
|
+
fragment.querySelector('body') || fragment.firstElementChild || fragment;
|
|
925
|
+
if (root.hasAttribute(':data')) root.removeAttribute(':data');
|
|
926
|
+
if (root.hasAttribute(':render')) root.removeAttribute(':render');
|
|
927
|
+
|
|
928
|
+
await renderer.mount(fragment);
|
|
929
|
+
|
|
930
|
+
const taskItems = fragment.querySelectorAll('[role="listitem"]');
|
|
931
|
+
assert.strictEqual(taskItems.length, 2);
|
|
932
|
+
|
|
933
|
+
// 1. Running Task
|
|
934
|
+
const runningDetails = taskItems[0].querySelector('.px-12.pb-3');
|
|
935
|
+
const startedAtLabel = Array.from(
|
|
936
|
+
runningDetails.querySelectorAll('span'),
|
|
937
|
+
).find((s) => s.textContent.trim() === 'Started At:');
|
|
938
|
+
assert.ok(
|
|
939
|
+
startedAtLabel,
|
|
940
|
+
'Started At label should be present for in-progress task',
|
|
941
|
+
);
|
|
942
|
+
const startedAtValue = startedAtLabel.nextElementSibling;
|
|
943
|
+
assert.ok(
|
|
944
|
+
startedAtValue.textContent.trim().length > 0,
|
|
945
|
+
'Started At value should not be empty',
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
const completedAtLabelR = Array.from(
|
|
949
|
+
runningDetails.querySelectorAll('span'),
|
|
950
|
+
).find((s) => s.textContent.trim() === 'Completed At:');
|
|
951
|
+
assert.ok(
|
|
952
|
+
!completedAtLabelR ||
|
|
953
|
+
completedAtLabelR.parentElement.style.display === 'none',
|
|
954
|
+
'Completed At label should NOT be present for in-progress task',
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
// 2. Completed Task
|
|
958
|
+
const completedDetails = taskItems[1].querySelector('.px-12.pb-3');
|
|
959
|
+
const completedAtLabel = Array.from(
|
|
960
|
+
completedDetails.querySelectorAll('span'),
|
|
961
|
+
).find((s) => s.textContent.trim() === 'Completed At:');
|
|
962
|
+
assert.ok(
|
|
963
|
+
completedAtLabel,
|
|
964
|
+
'Completed At label should be present for completed task',
|
|
965
|
+
);
|
|
966
|
+
const completedAtValue = completedAtLabel.nextElementSibling;
|
|
967
|
+
assert.ok(
|
|
968
|
+
completedAtValue.textContent.trim().length > 0,
|
|
969
|
+
'Completed At value should not be empty',
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
const startedAtLabelC = Array.from(
|
|
973
|
+
completedDetails.querySelectorAll('span'),
|
|
974
|
+
).find((s) => s.textContent.trim() === 'Started At:');
|
|
975
|
+
assert.ok(
|
|
976
|
+
!startedAtLabelC ||
|
|
977
|
+
startedAtLabelC.parentElement.style.display === 'none',
|
|
978
|
+
'Started At label should NOT be present for completed task',
|
|
979
|
+
);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test('renders copy task id button', async () => {
|
|
983
|
+
const tasks = [
|
|
984
|
+
{ id: '123', description: 'Test', status: 'pending', progress: [] },
|
|
985
|
+
];
|
|
986
|
+
const renderer = new Renderer(
|
|
987
|
+
createInitialState({
|
|
988
|
+
tasks,
|
|
989
|
+
loading: false,
|
|
990
|
+
}),
|
|
991
|
+
);
|
|
992
|
+
const fragment = await renderer.preprocessLocal(indexHtmlPath);
|
|
993
|
+
await renderer.mount(fragment);
|
|
994
|
+
|
|
995
|
+
const copyBtn = fragment.querySelector('[aria-label="Copy Task ID"]');
|
|
996
|
+
assert.ok(copyBtn, 'Copy Task ID button should exist');
|
|
997
|
+
});
|
|
998
|
+
});
|