lemming-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ });