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,233 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from unittest import mock
|
|
3
|
+
|
|
4
|
+
from lemming import paths
|
|
5
|
+
|
|
6
|
+
from .. import models, persistence
|
|
7
|
+
from . import queries
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_get_project_data_deduplication(tmp_path):
|
|
11
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
12
|
+
|
|
13
|
+
# Create a corrupted roadmap with duplicate task IDs
|
|
14
|
+
data = models.Roadmap(
|
|
15
|
+
context="test",
|
|
16
|
+
tasks=[
|
|
17
|
+
models.Task(
|
|
18
|
+
id="1", description="Task 1", status=models.TaskStatus.PENDING
|
|
19
|
+
),
|
|
20
|
+
models.Task(
|
|
21
|
+
id="1",
|
|
22
|
+
description="Task 1 Duplicate",
|
|
23
|
+
status=models.TaskStatus.PENDING,
|
|
24
|
+
),
|
|
25
|
+
models.Task(
|
|
26
|
+
id="2", description="Task 2", status=models.TaskStatus.PENDING
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
persistence.save_tasks(tasks_file, data)
|
|
31
|
+
|
|
32
|
+
project_data = queries.get_project_data(tasks_file)
|
|
33
|
+
|
|
34
|
+
# Should only have two unique tasks, oldest first (chronological)
|
|
35
|
+
assert len(project_data.tasks) == 2
|
|
36
|
+
assert project_data.tasks[0].description == "Task 1"
|
|
37
|
+
assert project_data.tasks[1].description == "Task 2"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_get_project_data_enriches_metadata(tmp_path):
|
|
41
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
42
|
+
data = models.Roadmap(
|
|
43
|
+
tasks=[
|
|
44
|
+
models.Task(
|
|
45
|
+
id="1", description="Task 1", status=models.TaskStatus.PENDING
|
|
46
|
+
),
|
|
47
|
+
models.Task(
|
|
48
|
+
id="2", description="Task 2", status=models.TaskStatus.PENDING
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
persistence.save_tasks(tasks_file, data)
|
|
53
|
+
|
|
54
|
+
# Create a dummy log file for Task 2
|
|
55
|
+
log_file = paths.get_log_file(tasks_file, "2")
|
|
56
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
log_file.write_text("dummy log")
|
|
58
|
+
|
|
59
|
+
project_data = queries.get_project_data(tasks_file)
|
|
60
|
+
|
|
61
|
+
# Check index and has_runner_log
|
|
62
|
+
t2 = next(t for t in project_data.tasks if t.id == "2")
|
|
63
|
+
t1 = next(t for t in project_data.tasks if t.id == "1")
|
|
64
|
+
|
|
65
|
+
assert t1.index == 0
|
|
66
|
+
assert t1.has_runner_log is False
|
|
67
|
+
|
|
68
|
+
assert t2.index == 1
|
|
69
|
+
assert t2.has_runner_log is True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_completed_tasks_sorting_newest_first(tmp_path):
|
|
73
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
74
|
+
data = models.Roadmap(
|
|
75
|
+
tasks=[
|
|
76
|
+
models.Task(
|
|
77
|
+
id="1",
|
|
78
|
+
description="Task 1",
|
|
79
|
+
status=models.TaskStatus.COMPLETED,
|
|
80
|
+
completed_at=100.0,
|
|
81
|
+
),
|
|
82
|
+
models.Task(
|
|
83
|
+
id="2",
|
|
84
|
+
description="Task 2",
|
|
85
|
+
status=models.TaskStatus.COMPLETED,
|
|
86
|
+
completed_at=200.0,
|
|
87
|
+
),
|
|
88
|
+
models.Task(
|
|
89
|
+
id="3",
|
|
90
|
+
description="Task 3",
|
|
91
|
+
status=models.TaskStatus.COMPLETED,
|
|
92
|
+
completed_at=300.0,
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
persistence.save_tasks(tasks_file, data)
|
|
97
|
+
|
|
98
|
+
project_data = queries.get_project_data(tasks_file)
|
|
99
|
+
completed_tasks = [
|
|
100
|
+
t for t in project_data.tasks if t.status == models.TaskStatus.COMPLETED
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Should be newest first
|
|
104
|
+
assert completed_tasks[0].description == "Task 3"
|
|
105
|
+
assert completed_tasks[1].description == "Task 2"
|
|
106
|
+
assert completed_tasks[2].description == "Task 1"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_uncompleted_tasks_sorting_prioritizes_in_progress(tmp_path):
|
|
110
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
111
|
+
data = models.Roadmap(
|
|
112
|
+
tasks=[
|
|
113
|
+
models.Task(
|
|
114
|
+
id="1",
|
|
115
|
+
description="Task 1",
|
|
116
|
+
status=models.TaskStatus.PENDING,
|
|
117
|
+
created_at=1.0,
|
|
118
|
+
),
|
|
119
|
+
models.Task(
|
|
120
|
+
id="2",
|
|
121
|
+
description="Task 2",
|
|
122
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
123
|
+
created_at=2.0,
|
|
124
|
+
),
|
|
125
|
+
models.Task(
|
|
126
|
+
id="3",
|
|
127
|
+
description="Task 3",
|
|
128
|
+
status=models.TaskStatus.PENDING,
|
|
129
|
+
created_at=3.0,
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
persistence.save_tasks(tasks_file, data)
|
|
134
|
+
|
|
135
|
+
project_data = queries.get_project_data(tasks_file)
|
|
136
|
+
uncompleted_tasks = [
|
|
137
|
+
t
|
|
138
|
+
for t in project_data.tasks
|
|
139
|
+
if t.status
|
|
140
|
+
not in (models.TaskStatus.COMPLETED, models.TaskStatus.FAILED)
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
# Should prioritize in_progress first, then chronological index
|
|
144
|
+
# Task 2 (in_progress), Task 1 (index 0), Task 3 (index 2)
|
|
145
|
+
assert uncompleted_tasks[0].description == "Task 2"
|
|
146
|
+
assert uncompleted_tasks[1].description == "Task 1"
|
|
147
|
+
assert uncompleted_tasks[2].description == "Task 3"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_failed_tasks_sorting_grouped_with_completed(tmp_path):
|
|
151
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
152
|
+
now = time.time()
|
|
153
|
+
data = models.Roadmap(
|
|
154
|
+
tasks=[
|
|
155
|
+
models.Task(
|
|
156
|
+
id="1",
|
|
157
|
+
description="Pending",
|
|
158
|
+
status=models.TaskStatus.PENDING,
|
|
159
|
+
created_at=now,
|
|
160
|
+
),
|
|
161
|
+
models.Task(
|
|
162
|
+
id="2",
|
|
163
|
+
description="In Progress",
|
|
164
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
165
|
+
created_at=now + 1,
|
|
166
|
+
),
|
|
167
|
+
models.Task(
|
|
168
|
+
id="3",
|
|
169
|
+
description="Failed",
|
|
170
|
+
status=models.TaskStatus.FAILED,
|
|
171
|
+
created_at=now + 2,
|
|
172
|
+
),
|
|
173
|
+
models.Task(
|
|
174
|
+
id="4",
|
|
175
|
+
description="Completed",
|
|
176
|
+
status=models.TaskStatus.COMPLETED,
|
|
177
|
+
completed_at=now + 1000.0,
|
|
178
|
+
created_at=now + 3,
|
|
179
|
+
),
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
persistence.save_tasks(tasks_file, data)
|
|
183
|
+
|
|
184
|
+
project_data = queries.get_project_data(tasks_file)
|
|
185
|
+
|
|
186
|
+
# Order should be:
|
|
187
|
+
# 1. In Progress (uncompleted, prioritized)
|
|
188
|
+
# 2. Pending (uncompleted, index 0)
|
|
189
|
+
# 3. Completed (completed/failed, newest)
|
|
190
|
+
# 4. Failed (completed/failed, older)
|
|
191
|
+
|
|
192
|
+
assert project_data.tasks[0].description == "In Progress"
|
|
193
|
+
assert project_data.tasks[1].description == "Pending"
|
|
194
|
+
assert project_data.tasks[2].description == "Completed"
|
|
195
|
+
assert project_data.tasks[3].description == "Failed"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_get_pending_task(tmp_path):
|
|
199
|
+
data = models.Roadmap(
|
|
200
|
+
tasks=[
|
|
201
|
+
models.Task(
|
|
202
|
+
id="1",
|
|
203
|
+
description="Task 1",
|
|
204
|
+
status=models.TaskStatus.PENDING,
|
|
205
|
+
created_at=1.0,
|
|
206
|
+
),
|
|
207
|
+
models.Task(
|
|
208
|
+
id="2",
|
|
209
|
+
description="Task 2",
|
|
210
|
+
status=models.TaskStatus.PENDING,
|
|
211
|
+
created_at=2.0,
|
|
212
|
+
),
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
pending = queries.get_pending_task(data)
|
|
216
|
+
assert pending.id == "1"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_get_pending_task_none_if_in_progress(tmp_path):
|
|
220
|
+
with mock.patch("lemming.tasks.lifecycle.is_pid_alive", return_value=True):
|
|
221
|
+
data = models.Roadmap(
|
|
222
|
+
tasks=[
|
|
223
|
+
models.Task(
|
|
224
|
+
id="1",
|
|
225
|
+
description="Task 1",
|
|
226
|
+
status=models.TaskStatus.IN_PROGRESS,
|
|
227
|
+
last_heartbeat=time.time(),
|
|
228
|
+
pid=12345,
|
|
229
|
+
)
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
pending = queries.get_pending_task(data)
|
|
233
|
+
assert pending is None
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { expect, test } from '@playwright/test';
|
|
4
|
+
|
|
5
|
+
const indexHtmlPath = path.resolve(process.cwd(), 'src/lemming/web/index.html');
|
|
6
|
+
|
|
7
|
+
test.describe('Dashboard E2E', () => {
|
|
8
|
+
test.beforeEach(async ({ page, context }) => {
|
|
9
|
+
// Serve the HTML file and static assets over mocked URLs
|
|
10
|
+
await context.route('**/*', async (route) => {
|
|
11
|
+
const url = route.request().url();
|
|
12
|
+
if (
|
|
13
|
+
url === 'http://localhost:8000/' ||
|
|
14
|
+
url.startsWith('http://localhost:8000/?')
|
|
15
|
+
) {
|
|
16
|
+
await route.fulfill({
|
|
17
|
+
contentType: 'text/html',
|
|
18
|
+
body: fs.readFileSync(indexHtmlPath, 'utf8'),
|
|
19
|
+
});
|
|
20
|
+
} else if (url.endsWith('/static/mancha.js')) {
|
|
21
|
+
await route.fulfill({
|
|
22
|
+
contentType: 'application/javascript',
|
|
23
|
+
body: fs.readFileSync(
|
|
24
|
+
path.resolve(process.cwd(), 'src/lemming/web/mancha.js'),
|
|
25
|
+
'utf8',
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
} else if (url.endsWith('/static/favicon.js')) {
|
|
29
|
+
await route.fulfill({
|
|
30
|
+
contentType: 'application/javascript',
|
|
31
|
+
body: fs.readFileSync(
|
|
32
|
+
path.resolve(process.cwd(), 'src/lemming/web/favicon.js'),
|
|
33
|
+
'utf8',
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
} else if (url.endsWith('/static/index.js')) {
|
|
37
|
+
await route.fulfill({
|
|
38
|
+
contentType: 'application/javascript',
|
|
39
|
+
body: fs.readFileSync(
|
|
40
|
+
path.resolve(process.cwd(), 'src/lemming/web/index.js'),
|
|
41
|
+
'utf8',
|
|
42
|
+
),
|
|
43
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
44
|
+
});
|
|
45
|
+
} else if (url.includes('/api/data')) {
|
|
46
|
+
await route.fulfill({
|
|
47
|
+
contentType: 'application/json',
|
|
48
|
+
json: {
|
|
49
|
+
cwd: '/mock/cwd',
|
|
50
|
+
loop_running: false,
|
|
51
|
+
tasks: [],
|
|
52
|
+
context: 'Mock context',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} else if (url.includes('/api/runners')) {
|
|
56
|
+
await route.fulfill({
|
|
57
|
+
contentType: 'application/json',
|
|
58
|
+
json: ['agy', 'aider'],
|
|
59
|
+
});
|
|
60
|
+
} else if (url.includes('/api/hooks')) {
|
|
61
|
+
await route.fulfill({
|
|
62
|
+
contentType: 'application/json',
|
|
63
|
+
json: ['roadmap'],
|
|
64
|
+
});
|
|
65
|
+
} else if (
|
|
66
|
+
url.includes('/api/directories') &&
|
|
67
|
+
route.request().method() === 'POST'
|
|
68
|
+
) {
|
|
69
|
+
await route.fulfill({
|
|
70
|
+
contentType: 'application/json',
|
|
71
|
+
json: { name: 'new-folder', path: '/mock/cwd/new-folder' },
|
|
72
|
+
});
|
|
73
|
+
} else if (url.includes('/api/directories')) {
|
|
74
|
+
await route.fulfill({
|
|
75
|
+
contentType: 'application/json',
|
|
76
|
+
json: {
|
|
77
|
+
status: 'success',
|
|
78
|
+
path: '/mock/cwd',
|
|
79
|
+
directories: [{ name: 'subdir', path: '/mock/cwd/subdir' }],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
await route.continue();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
async function gotoAndAwaitMancha(page) {
|
|
89
|
+
await page.goto('http://localhost:8000/');
|
|
90
|
+
await page.evaluate(async () => {
|
|
91
|
+
while (!window.ManchaApp) await new Promise((r) => setTimeout(r, 50));
|
|
92
|
+
await window.ManchaApp;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Environment Overrides Tests ---
|
|
97
|
+
|
|
98
|
+
test('adds an environment override, stores it in localStorage, and sends it on run', async ({
|
|
99
|
+
page,
|
|
100
|
+
}) => {
|
|
101
|
+
let runRequestPayload = null;
|
|
102
|
+
await page.route('**/api/run', async (route) => {
|
|
103
|
+
runRequestPayload = route.request().postDataJSON();
|
|
104
|
+
await route.fulfill({
|
|
105
|
+
contentType: 'application/json',
|
|
106
|
+
json: { status: 'started' },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await gotoAndAwaitMancha(page);
|
|
111
|
+
await page.waitForLoadState('networkidle');
|
|
112
|
+
|
|
113
|
+
await page.evaluate(() => localStorage.clear());
|
|
114
|
+
await gotoAndAwaitMancha(page);
|
|
115
|
+
|
|
116
|
+
const addButton = page.getByRole('button', { name: 'Add override' });
|
|
117
|
+
await expect(addButton).toBeVisible();
|
|
118
|
+
await addButton.click();
|
|
119
|
+
|
|
120
|
+
const keyInput = page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)');
|
|
121
|
+
const valueInput = page.getByPlaceholder('VALUE');
|
|
122
|
+
|
|
123
|
+
await expect(keyInput).toBeVisible();
|
|
124
|
+
await keyInput.fill('MY_MOCK_KEY');
|
|
125
|
+
await valueInput.fill('MY_MOCK_VALUE');
|
|
126
|
+
|
|
127
|
+
await page.waitForTimeout(600);
|
|
128
|
+
|
|
129
|
+
const localStorageData = await page.evaluate(() =>
|
|
130
|
+
localStorage.getItem('lemming[]_env_overrides'),
|
|
131
|
+
);
|
|
132
|
+
expect(localStorageData).toContain('MY_MOCK_KEY');
|
|
133
|
+
expect(localStorageData).toContain('MY_MOCK_VALUE');
|
|
134
|
+
|
|
135
|
+
const runResponsePromise = page.waitForResponse('**/api/run');
|
|
136
|
+
await page.getByRole('button', { name: 'Execute Tasks' }).click();
|
|
137
|
+
await runResponsePromise;
|
|
138
|
+
|
|
139
|
+
expect(runRequestPayload).not.toBeNull();
|
|
140
|
+
expect(runRequestPayload.env).toEqual({
|
|
141
|
+
MY_MOCK_KEY: 'MY_MOCK_VALUE',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('different projects store different environment overrides', async ({
|
|
146
|
+
page,
|
|
147
|
+
}) => {
|
|
148
|
+
// Project A
|
|
149
|
+
await page.goto('http://localhost:8000/?project=ProjectA');
|
|
150
|
+
await page.evaluate(async () => {
|
|
151
|
+
while (!window.ManchaApp) await new Promise((r) => setTimeout(r, 50));
|
|
152
|
+
await window.ManchaApp;
|
|
153
|
+
});
|
|
154
|
+
await page.waitForLoadState('networkidle');
|
|
155
|
+
|
|
156
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
157
|
+
await page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)').fill('KEY_A');
|
|
158
|
+
await page.getByPlaceholder('VALUE').fill('VAL_A');
|
|
159
|
+
await page.waitForTimeout(600); // debounce
|
|
160
|
+
|
|
161
|
+
// Project B
|
|
162
|
+
await page.goto('http://localhost:8000/?project=ProjectB');
|
|
163
|
+
await page.evaluate(async () => {
|
|
164
|
+
while (!window.ManchaApp) await new Promise((r) => setTimeout(r, 50));
|
|
165
|
+
await window.ManchaApp;
|
|
166
|
+
});
|
|
167
|
+
await page.waitForLoadState('networkidle');
|
|
168
|
+
|
|
169
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
170
|
+
await page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)').fill('KEY_B');
|
|
171
|
+
await page.getByPlaceholder('VALUE').fill('VAL_B');
|
|
172
|
+
await page.waitForTimeout(600); // debounce
|
|
173
|
+
|
|
174
|
+
// Verify Project B only has KEY_B
|
|
175
|
+
const inputsB = page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)');
|
|
176
|
+
await expect(inputsB).toHaveCount(1);
|
|
177
|
+
await expect(inputsB).toHaveValue('KEY_B');
|
|
178
|
+
|
|
179
|
+
// Go back to Project A
|
|
180
|
+
await page.goto('http://localhost:8000/?project=ProjectA');
|
|
181
|
+
await page.evaluate(async () => {
|
|
182
|
+
while (!window.ManchaApp) await new Promise((r) => setTimeout(r, 50));
|
|
183
|
+
await window.ManchaApp;
|
|
184
|
+
});
|
|
185
|
+
await page.waitForLoadState('networkidle');
|
|
186
|
+
|
|
187
|
+
// Verify Project A still has KEY_A
|
|
188
|
+
const inputsA = page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)');
|
|
189
|
+
await expect(inputsA).toHaveCount(1);
|
|
190
|
+
await expect(inputsA).toHaveValue('KEY_A');
|
|
191
|
+
|
|
192
|
+
// Check localStorage keys directly
|
|
193
|
+
const keys = await page.evaluate(() => Object.keys(localStorage));
|
|
194
|
+
expect(keys).toContain('lemming[ProjectA]_env_overrides');
|
|
195
|
+
expect(keys).toContain('lemming[ProjectB]_env_overrides');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('removes an environment override', async ({ page }) => {
|
|
199
|
+
await gotoAndAwaitMancha(page);
|
|
200
|
+
await page.evaluate(() => localStorage.clear());
|
|
201
|
+
await gotoAndAwaitMancha(page);
|
|
202
|
+
|
|
203
|
+
const keyInputs = page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)');
|
|
204
|
+
|
|
205
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
206
|
+
await expect(keyInputs).toHaveCount(1);
|
|
207
|
+
|
|
208
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
209
|
+
await expect(keyInputs).toHaveCount(2);
|
|
210
|
+
|
|
211
|
+
await keyInputs.nth(0).fill('KEY1');
|
|
212
|
+
await keyInputs.nth(1).fill('KEY2');
|
|
213
|
+
|
|
214
|
+
await page.waitForTimeout(500);
|
|
215
|
+
|
|
216
|
+
const removeButtons = page.getByRole('button', { name: 'Remove override' });
|
|
217
|
+
await removeButtons.nth(0).click();
|
|
218
|
+
|
|
219
|
+
await expect(keyInputs).toHaveCount(1);
|
|
220
|
+
await expect(keyInputs.nth(0)).toHaveValue('KEY2');
|
|
221
|
+
|
|
222
|
+
await page.waitForTimeout(600);
|
|
223
|
+
|
|
224
|
+
const localStorageData = await page.evaluate(() =>
|
|
225
|
+
localStorage.getItem('lemming[]_env_overrides'),
|
|
226
|
+
);
|
|
227
|
+
expect(localStorageData).toContain('KEY2');
|
|
228
|
+
expect(localStorageData).not.toContain('KEY1');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('ignores empty keys when sending payload', async ({ page }) => {
|
|
232
|
+
let runRequestPayload = null;
|
|
233
|
+
await page.route('**/api/run', async (route) => {
|
|
234
|
+
runRequestPayload = route.request().postDataJSON();
|
|
235
|
+
await route.fulfill({
|
|
236
|
+
contentType: 'application/json',
|
|
237
|
+
json: { status: 'started' },
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await gotoAndAwaitMancha(page);
|
|
242
|
+
await page.evaluate(() => localStorage.clear());
|
|
243
|
+
await gotoAndAwaitMancha(page);
|
|
244
|
+
|
|
245
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
246
|
+
const keyInputs = page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)');
|
|
247
|
+
await expect(keyInputs).toHaveCount(1);
|
|
248
|
+
|
|
249
|
+
await page.waitForTimeout(600);
|
|
250
|
+
|
|
251
|
+
const runResponsePromise = page.waitForResponse('**/api/run');
|
|
252
|
+
await page.getByRole('button', { name: 'Execute Tasks' }).click();
|
|
253
|
+
await runResponsePromise;
|
|
254
|
+
|
|
255
|
+
expect(runRequestPayload).not.toBeNull();
|
|
256
|
+
expect(runRequestPayload.env).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('persists overrides across page reloads', async ({ page }) => {
|
|
260
|
+
await gotoAndAwaitMancha(page);
|
|
261
|
+
await page.evaluate(() => localStorage.clear());
|
|
262
|
+
await gotoAndAwaitMancha(page);
|
|
263
|
+
|
|
264
|
+
// Add override
|
|
265
|
+
await page.getByRole('button', { name: 'Add override' }).click();
|
|
266
|
+
await page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)').fill('TEST_KEY');
|
|
267
|
+
await page.getByPlaceholder('VALUE').fill('TEST_VAL');
|
|
268
|
+
|
|
269
|
+
// Wait for save debounce
|
|
270
|
+
await page.waitForTimeout(600);
|
|
271
|
+
|
|
272
|
+
// Reload page
|
|
273
|
+
await page.reload();
|
|
274
|
+
await page.waitForLoadState('networkidle');
|
|
275
|
+
|
|
276
|
+
// Check that we're dealing with Mancha actually being ready
|
|
277
|
+
await expect(
|
|
278
|
+
page.getByRole('heading', { name: 'Lemming Task Runner' }),
|
|
279
|
+
).toBeVisible();
|
|
280
|
+
|
|
281
|
+
// Give render time
|
|
282
|
+
await page.waitForTimeout(500);
|
|
283
|
+
|
|
284
|
+
// Verify values restored
|
|
285
|
+
await expect(
|
|
286
|
+
page.getByPlaceholder('KEY (e.g. OPENAI_API_KEY)'),
|
|
287
|
+
).toHaveValue('TEST_KEY');
|
|
288
|
+
await expect(page.getByPlaceholder('VALUE')).toHaveValue('TEST_VAL');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// --- Folder Picker Tests ---
|
|
292
|
+
|
|
293
|
+
test('selecting a folder opens it in a new tab', async ({
|
|
294
|
+
page,
|
|
295
|
+
context,
|
|
296
|
+
}) => {
|
|
297
|
+
await gotoAndAwaitMancha(page);
|
|
298
|
+
await page.waitForLoadState('networkidle');
|
|
299
|
+
|
|
300
|
+
// Open folder picker
|
|
301
|
+
await page.click('button[title="Switch project"]');
|
|
302
|
+
await page.waitForSelector('#folder-picker-modal[open]');
|
|
303
|
+
|
|
304
|
+
// Select a folder and wait for the popup (new tab)
|
|
305
|
+
const [popup] = await Promise.all([
|
|
306
|
+
page.waitForEvent('popup'),
|
|
307
|
+
page.click('button:has-text("Select This Folder")'),
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
// Verify the popup URL contains the project parameter
|
|
311
|
+
expect(popup.url()).toContain('project=%2Fmock%2Fcwd');
|
|
312
|
+
|
|
313
|
+
// Verify modal is closed in the original page
|
|
314
|
+
await expect(page.locator('#folder-picker-modal')).not.toHaveAttribute(
|
|
315
|
+
'open',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('creating a new folder', async ({ page }) => {
|
|
320
|
+
await gotoAndAwaitMancha(page);
|
|
321
|
+
await page.waitForLoadState('networkidle');
|
|
322
|
+
|
|
323
|
+
// Open folder picker
|
|
324
|
+
await page.click('button[title="Switch project"]');
|
|
325
|
+
await page.waitForSelector('#folder-picker-modal[open]');
|
|
326
|
+
|
|
327
|
+
// Wait for the button and ensure it's visible
|
|
328
|
+
const newFolderBtn = page.locator('button[title="Create new folder"]');
|
|
329
|
+
await expect(newFolderBtn).toBeVisible();
|
|
330
|
+
|
|
331
|
+
// Click "New Folder" button
|
|
332
|
+
await newFolderBtn.click();
|
|
333
|
+
|
|
334
|
+
// Fill the folder name
|
|
335
|
+
await page.fill('input[placeholder="Folder name"]', 'new-folder');
|
|
336
|
+
|
|
337
|
+
// Click "Create" button
|
|
338
|
+
await page.click('button:has-text("Create")');
|
|
339
|
+
|
|
340
|
+
// Verify toast notification (mocked behavior)
|
|
341
|
+
await expect(page.locator('div[role="alert"]')).toContainText(
|
|
342
|
+
'Folder created!',
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// The form should be hidden again
|
|
346
|
+
await expect(
|
|
347
|
+
page.locator('input[placeholder="Folder name"]'),
|
|
348
|
+
).not.toBeVisible();
|
|
349
|
+
});
|
|
350
|
+
});
|