plum-e2e 1.3.7 → 2.1.0

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 (45) hide show
  1. package/README.md +61 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/middleware/jwtAuth.js +33 -0
  6. package/backend/middleware/requireAdmin.js +25 -0
  7. package/backend/package.json +2 -0
  8. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  9. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  10. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  11. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  12. package/backend/prisma/schema.prisma +118 -10
  13. package/backend/routes/auth.routes.js +96 -0
  14. package/backend/routes/settings.routes.js +44 -8
  15. package/backend/routes/test-cases.routes.js +80 -0
  16. package/backend/routes/test-runs.routes.js +122 -0
  17. package/backend/routes/test-suites.routes.js +92 -0
  18. package/backend/routes/users.routes.js +67 -0
  19. package/backend/scripts/create-test.js +7 -6
  20. package/backend/services/reportService.js +96 -4
  21. package/backend/services/settingsService.js +18 -2
  22. package/backend/services/testCaseService.js +139 -0
  23. package/backend/services/testRunService.js +203 -0
  24. package/backend/services/testSuiteService.js +191 -0
  25. package/backend/services/userService.js +114 -0
  26. package/backend/websockets/socketHandler.js +19 -6
  27. package/bin/plum.js +105 -9
  28. package/frontend/src/lib/api/auth.js +69 -0
  29. package/frontend/src/lib/api/repository.js +256 -0
  30. package/frontend/src/lib/api/users.js +52 -0
  31. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  32. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  33. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  34. package/frontend/src/lib/constants.js +2 -0
  35. package/frontend/src/lib/stores/auth.js +60 -0
  36. package/frontend/src/lib/stores/runner.js +9 -2
  37. package/frontend/src/routes/+layout.svelte +32 -4
  38. package/frontend/src/routes/+page.svelte +1 -1
  39. package/frontend/src/routes/login/+page.svelte +209 -0
  40. package/frontend/src/routes/settings/+page.svelte +586 -5
  41. package/frontend/src/routes/setup/+page.svelte +249 -0
  42. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  43. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  44. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  45. package/package.json +1 -1
@@ -0,0 +1,114 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ const bcrypt = require('bcryptjs');
19
+ const jwt = require('jsonwebtoken');
20
+ const prisma = require('./prisma');
21
+
22
+ const JWT_SECRET = process.env.JWT_SECRET || 'plum-dev-secret-change-in-production';
23
+ const JWT_EXPIRY = '7d';
24
+ const SALT_ROUNDS = 10;
25
+
26
+ const userSelect = { id: true, name: true, email: true, role: true, createdAt: true };
27
+
28
+ async function needsSetup() {
29
+ const count = await prisma.user.count();
30
+ return count === 0;
31
+ }
32
+
33
+ async function createUser({ name, email, password, role = 'user' }) {
34
+ const hashed = await bcrypt.hash(password, SALT_ROUNDS);
35
+ return prisma.user.create({
36
+ data: { name, email, password: hashed, role },
37
+ select: userSelect
38
+ });
39
+ }
40
+
41
+ async function login({ email, password }) {
42
+ const user = await prisma.user.findUnique({ where: { email } });
43
+ if (!user) return null;
44
+ const match = await bcrypt.compare(password, user.password);
45
+ if (!match) return null;
46
+ const token = jwt.sign(
47
+ { userId: user.id, email: user.email, name: user.name, role: user.role },
48
+ JWT_SECRET,
49
+ { expiresIn: JWT_EXPIRY }
50
+ );
51
+ return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
52
+ }
53
+
54
+ function verifyToken(token) {
55
+ return jwt.verify(token, JWT_SECRET);
56
+ }
57
+
58
+ async function getAll() {
59
+ return prisma.user.findMany({ select: userSelect, orderBy: { createdAt: 'asc' } });
60
+ }
61
+
62
+ async function getMembers() {
63
+ return prisma.user.findMany({
64
+ select: { id: true, name: true },
65
+ orderBy: { name: 'asc' }
66
+ });
67
+ }
68
+
69
+ async function getById(id) {
70
+ return prisma.user.findUnique({ where: { id }, select: userSelect });
71
+ }
72
+
73
+ async function updateProfile(id, { name, email }) {
74
+ if (email) {
75
+ const conflict = await prisma.user.findFirst({ where: { email, NOT: { id } } });
76
+ if (conflict) return { ok: false, error: 'Email already in use' };
77
+ }
78
+ const user = await prisma.user.update({
79
+ where: { id },
80
+ data: {
81
+ ...(name !== undefined && { name }),
82
+ ...(email !== undefined && { email })
83
+ },
84
+ select: userSelect
85
+ });
86
+ return { ok: true, user };
87
+ }
88
+
89
+ async function updatePassword(id, { currentPassword, newPassword }) {
90
+ const user = await prisma.user.findUnique({ where: { id } });
91
+ if (!user) return { ok: false, error: 'User not found' };
92
+ const match = await bcrypt.compare(currentPassword, user.password);
93
+ if (!match) return { ok: false, error: 'Current password is incorrect' };
94
+ const hashed = await bcrypt.hash(newPassword, SALT_ROUNDS);
95
+ await prisma.user.update({ where: { id }, data: { password: hashed } });
96
+ return { ok: true };
97
+ }
98
+
99
+ async function deleteUser(id) {
100
+ return prisma.user.delete({ where: { id } });
101
+ }
102
+
103
+ module.exports = {
104
+ needsSetup,
105
+ createUser,
106
+ login,
107
+ verifyToken,
108
+ getAll,
109
+ getMembers,
110
+ getById,
111
+ updateProfile,
112
+ updatePassword,
113
+ deleteUser
114
+ };
@@ -30,12 +30,13 @@ const socketHandler = (io) => {
30
30
  const activeProcs = new Set();
31
31
 
32
32
  socket.on('run-test', async (payload, legacyWorkers) => {
33
- let tag, workers, browser, runners;
33
+ let tag, workers, browser, runners, testRunId;
34
34
  if (typeof payload === 'string') {
35
35
  tag = payload;
36
36
  workers = Number(legacyWorkers) > 1 ? Number(legacyWorkers) : 1;
37
37
  browser = 'chromium';
38
38
  runners = [BUILT_IN_RUNNER_ID];
39
+ testRunId = null;
39
40
  } else {
40
41
  tag = payload.tag ?? '';
41
42
  workers = Number(payload.workers) > 1 ? Number(payload.workers) : 1;
@@ -44,6 +45,7 @@ const socketHandler = (io) => {
44
45
  Array.isArray(payload.runners) && payload.runners.length > 0
45
46
  ? payload.runners
46
47
  : [BUILT_IN_RUNNER_ID];
48
+ testRunId = payload.testRunId ?? null;
47
49
  }
48
50
 
49
51
  // Drop runner ids that no longer exist (e.g. a deleted runner still
@@ -61,9 +63,9 @@ const socketHandler = (io) => {
61
63
  const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
62
64
 
63
65
  if (isSingleBuiltIn) {
64
- runBuiltIn(io, socket, activeProcs, tag, workers, browser);
66
+ runBuiltIn(io, socket, activeProcs, tag, workers, browser, testRunId);
65
67
  } else {
66
- runDistributed(io, socket, activeProcs, tag, workers, browser, runners);
68
+ runDistributed(io, socket, activeProcs, tag, workers, browser, runners, testRunId);
67
69
  }
68
70
  });
69
71
 
@@ -85,7 +87,7 @@ const socketHandler = (io) => {
85
87
  // Single built-in runner
86
88
  // ---------------------------------------------------------------------------
87
89
 
88
- function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
90
+ function runBuiltIn(io, socket, activeProcs, tag, workers, browser, testRunId) {
89
91
  const env = {
90
92
  ...process.env,
91
93
  TAG: tag,
@@ -94,6 +96,7 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
94
96
  BROWSER: browser
95
97
  };
96
98
  if (workers > 1) env.PARALLEL = String(workers);
99
+ if (testRunId) env.TEST_RUN_ID = testRunId;
97
100
 
98
101
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
99
102
  activeProcs.add(proc);
@@ -114,7 +117,16 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
114
117
  // Distributed (multi-runner) path
115
118
  // ---------------------------------------------------------------------------
116
119
 
117
- async function runDistributed(io, socket, activeProcs, tag, workers, browser, runnerIds) {
120
+ async function runDistributed(
121
+ io,
122
+ socket,
123
+ activeProcs,
124
+ tag,
125
+ workers,
126
+ browser,
127
+ runnerIds,
128
+ testRunId
129
+ ) {
118
130
  const allIds = getTestIdsForTag(tag);
119
131
  const chunks = chunkTests(allIds, runnerIds.length);
120
132
 
@@ -170,7 +182,8 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
170
182
  overallCode,
171
183
  tag,
172
184
  triggerType: TRIGGER_TYPE.MANUAL,
173
- browser
185
+ browser,
186
+ testRunId: testRunId ?? null
174
187
  })
175
188
  .then((saved) => {
176
189
  // Result is authoritative from the merged report, not the exit code —
package/bin/plum.js CHANGED
@@ -279,8 +279,75 @@ async function serverStart() {
279
279
  applyServerConfig(cfg);
280
280
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
281
281
  clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
282
- execSync('docker compose up --build', { cwd: plumRoot, stdio: 'inherit' });
283
- clack.outro(pc.dim('Plum server stopped.'));
282
+
283
+ execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
284
+
285
+ const apiBase = `http://localhost:${cfg.backendPort}`;
286
+ const s = clack.spinner();
287
+ s.start('Waiting for server to be ready…');
288
+ let ready = false;
289
+ for (let i = 0; i < 40; i++) {
290
+ await new Promise((r) => setTimeout(r, 1500));
291
+ try {
292
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
293
+ if (res.ok) {
294
+ ready = true;
295
+ break;
296
+ }
297
+ } catch {}
298
+ }
299
+ s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
300
+
301
+ if (ready) {
302
+ let needsSetup = false;
303
+ try {
304
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
305
+ const data = await res.json();
306
+ needsSetup = data.needsSetup;
307
+ } catch {}
308
+
309
+ if (needsSetup) {
310
+ clack.log.info('No users found — create your first account to get started.');
311
+
312
+ const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
313
+ if (clack.isCancel(name)) {
314
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
315
+ } else {
316
+ const email = await clack.text({
317
+ message: 'Email address',
318
+ placeholder: 'jane@example.com'
319
+ });
320
+ if (clack.isCancel(email)) {
321
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
322
+ } else {
323
+ const password = await clack.password({ message: 'Password (min 8 characters)' });
324
+ if (clack.isCancel(password)) {
325
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
326
+ } else {
327
+ try {
328
+ const res = await fetch(`${apiBase}/auth/setup`, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({ name, email, password })
332
+ });
333
+ if (res.ok) {
334
+ clack.log.success(`Account created for ${email}. You can now log in.`);
335
+ } else {
336
+ const err = await res.json();
337
+ clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
338
+ }
339
+ } catch (e) {
340
+ clack.log.error(`Failed to create account: ${e.message}`);
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ clack.log.info(pc.dim('Streaming logs (Ctrl+C to detach — server keeps running):'));
349
+ execSync('docker compose logs -f', { cwd: plumRoot, stdio: 'inherit' });
350
+ clack.outro(pc.dim('Detached from logs. Run "docker compose down" to stop.'));
284
351
  }
285
352
 
286
353
  async function serverReconfig() {
@@ -588,7 +655,7 @@ switch (command) {
588
655
  const readmeContent = [
589
656
  '# My Tests',
590
657
  '',
591
- 'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber.',
658
+ 'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber + Test Repository.',
592
659
  '',
593
660
  '## Getting Started',
594
661
  '',
@@ -599,15 +666,16 @@ switch (command) {
599
666
  ' ```bash',
600
667
  ' plum run-test',
601
668
  ' ```',
602
- '3. **Write your first test** — edit a file in `tests/features/` or generate a step:',
669
+ '3. **Write your first test** — scaffold a full feature or generate a single step:',
603
670
  ' ```bash',
604
- ' plum create-step',
671
+ ' plum create-test # scaffold .feature + Page.ts + Steps.ts',
672
+ ' plum create-step # add a single step to an existing file',
605
673
  ' ```',
606
- '4. **Start the full UI** (requires Docker) to trigger tests and view reports in the browser:',
674
+ '4. **Start the full UI** (requires Docker) to trigger tests, view reports, and manage your test repository:',
607
675
  ' ```bash',
608
676
  ' plum start',
609
677
  ' ```',
610
- ' Then open **http://localhost:5173**.',
678
+ ' On first run, Plum asks you to create an admin account. Then open **http://localhost:5173** and sign in.',
611
679
  '',
612
680
  '---',
613
681
  '',
@@ -667,6 +735,19 @@ switch (command) {
667
735
  '',
668
736
  '---',
669
737
  '',
738
+ '## Test Repository',
739
+ '',
740
+ 'Plum includes a built-in test case management system. Access it from the **Test Repository** tab in the UI.',
741
+ '',
742
+ '- **Test Suites** — Group related test cases. Each suite gets an auto-assigned ID (e.g. `TS-001`).',
743
+ '- **Test Cases** — Document steps (Action / Test Data / Expected Output), set priority, and assign a Cucumber `@tag` to link automation.',
744
+ '- **Test Runs** — Build a run from any combination of cases, execute them one by one (pass/fail/blocked/skip), and track history.',
745
+ '- **Auto-linking** — When a build completes, Plum matches Cucumber scenario tags against `automatedTag` values on your test cases and marks them as automated.',
746
+ '',
747
+ 'To link a test case to automation, set its **Automated tag** (e.g. `test-login-1`) to match the `@tag` on the Cucumber scenario.',
748
+ '',
749
+ '---',
750
+ '',
670
751
  '## Cucumber & Gherkin Resources',
671
752
  '',
672
753
  'New to Cucumber? These links will get you up to speed quickly:',
@@ -700,8 +781,9 @@ switch (command) {
700
781
  `${pc.bold('Start the full UI')} ${pc.dim('(requires Docker)')}`,
701
782
  ` ${pc.cyan('plum server start')}`,
702
783
  '',
703
- `${pc.bold('Generate a step definition')}`,
704
- ` ${pc.cyan('plum create-step')}`
784
+ `${pc.bold('Generate tests')}`,
785
+ ` ${pc.cyan('plum create-test')} scaffold a new feature`,
786
+ ` ${pc.cyan('plum create-step')} add a step definition`
705
787
  ].join('\n'),
706
788
  'Next steps'
707
789
  );
@@ -885,6 +967,19 @@ switch (command) {
885
967
  break;
886
968
  }
887
969
 
970
+ case 'create-test': {
971
+ const createTestScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-test.mjs');
972
+ execSync(`node "${createTestScript}"`, {
973
+ cwd: process.cwd(),
974
+ stdio: 'inherit',
975
+ env: {
976
+ ...process.env,
977
+ TESTS_ROOT: userTestsPath
978
+ }
979
+ });
980
+ break;
981
+ }
982
+
888
983
  default:
889
984
  console.log('--------------------------------------\n');
890
985
  console.log('Usage: plum <command>\n');
@@ -918,5 +1013,6 @@ switch (command) {
918
1013
  console.log(' --parallel <n> Run across n parallel workers');
919
1014
  console.log(' --browser <name> chromium | firefox (default: chromium)');
920
1015
  console.log(' create-step Interactively scaffold a new step definition');
1016
+ console.log(' create-test Scaffold a new .feature + Page.ts + Steps.ts');
921
1017
  console.log('\n--------------------------------------\n');
922
1018
  }
@@ -0,0 +1,69 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ import { API_BASE } from '$lib/constants';
19
+
20
+ export async function checkNeedsSetup() {
21
+ const res = await fetch(`${API_BASE}/auth/needs-setup`);
22
+ if (!res.ok) return false;
23
+ const data = await res.json();
24
+ return data.needsSetup;
25
+ }
26
+
27
+ export async function setup({ name, email, password }) {
28
+ const res = await fetch(`${API_BASE}/auth/setup`, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ name, email, password })
32
+ });
33
+ const data = await res.json();
34
+ if (!res.ok) throw new Error(data.error ?? 'Setup failed');
35
+ return data;
36
+ }
37
+
38
+ export async function login({ email, password }) {
39
+ const res = await fetch(`${API_BASE}/auth/login`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ email, password })
43
+ });
44
+ const data = await res.json();
45
+ if (!res.ok) throw new Error(data.error ?? 'Login failed');
46
+ return data;
47
+ }
48
+
49
+ export async function updateProfile({ token, name, email }) {
50
+ const res = await fetch(`${API_BASE}/auth/update-profile`, {
51
+ method: 'PUT',
52
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
53
+ body: JSON.stringify({ name, email })
54
+ });
55
+ const data = await res.json();
56
+ if (!res.ok) throw new Error(data.error ?? 'Failed to update profile');
57
+ return data;
58
+ }
59
+
60
+ export async function changePassword({ token, currentPassword, newPassword }) {
61
+ const res = await fetch(`${API_BASE}/auth/change-password`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
64
+ body: JSON.stringify({ currentPassword, newPassword })
65
+ });
66
+ const data = await res.json();
67
+ if (!res.ok) throw new Error(data.error ?? 'Failed to change password');
68
+ return data;
69
+ }
@@ -0,0 +1,256 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ import { API_BASE, REPO_PAGE_SIZE } from '$lib/constants';
19
+ import { auth } from '$lib/stores/auth';
20
+
21
+ function getToken() {
22
+ return auth.getToken();
23
+ }
24
+
25
+ function authHeaders() {
26
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` };
27
+ }
28
+
29
+ // ── Test Suites ──────────────────────────────────────────────────────────────
30
+
31
+ export async function fetchSuites({ page = 1, limit = REPO_PAGE_SIZE, sortBy, sortOrder } = {}) {
32
+ const params = new URLSearchParams({ page, limit });
33
+ if (sortBy) params.set('sortBy', sortBy);
34
+ if (sortOrder) params.set('sortOrder', sortOrder);
35
+ const res = await fetch(`${API_BASE}/test-suites?${params}`, { headers: authHeaders() });
36
+ if (!res.ok) throw new Error('Failed to fetch suites');
37
+ return res.json(); // { suites, total }
38
+ }
39
+
40
+ export async function fetchAllSuitesWithCases() {
41
+ const res = await fetch(`${API_BASE}/test-suites?withCases=true`, { headers: authHeaders() });
42
+ if (!res.ok) throw new Error('Failed to fetch suites');
43
+ const data = await res.json();
44
+ return data.suites;
45
+ }
46
+
47
+ export async function searchRepository(q) {
48
+ const enc = encodeURIComponent(q);
49
+ const [sRes, rRes] = await Promise.all([
50
+ fetch(`${API_BASE}/test-suites?q=${enc}`, { headers: authHeaders() }),
51
+ fetch(`${API_BASE}/test-runs?q=${enc}&limit=50`, { headers: authHeaders() })
52
+ ]);
53
+ const { suites, cases } = await sRes.json();
54
+ const { runs } = await rRes.json();
55
+ return { suites, cases, runs };
56
+ }
57
+
58
+ export async function fetchSuite(id) {
59
+ const res = await fetch(`${API_BASE}/test-suites/${id}`, { headers: authHeaders() });
60
+ if (!res.ok) throw new Error('Suite not found');
61
+ const data = await res.json();
62
+ return data.suite;
63
+ }
64
+
65
+ export async function createSuite({ name, description, priority }) {
66
+ const res = await fetch(`${API_BASE}/test-suites`, {
67
+ method: 'POST',
68
+ headers: authHeaders(),
69
+ body: JSON.stringify({ name, description, priority })
70
+ });
71
+ const data = await res.json();
72
+ if (!res.ok) throw new Error(data.error ?? 'Failed to create suite');
73
+ return data.suite;
74
+ }
75
+
76
+ export async function updateSuite(id, { name, description, priority }) {
77
+ const res = await fetch(`${API_BASE}/test-suites/${id}`, {
78
+ method: 'PUT',
79
+ headers: authHeaders(),
80
+ body: JSON.stringify({ name, description, priority })
81
+ });
82
+ const data = await res.json();
83
+ if (!res.ok) throw new Error(data.error ?? 'Failed to update suite');
84
+ return data.suite;
85
+ }
86
+
87
+ export async function deleteSuite(id) {
88
+ const res = await fetch(`${API_BASE}/test-suites/${id}`, {
89
+ method: 'DELETE',
90
+ headers: authHeaders()
91
+ });
92
+ if (!res.ok) throw new Error('Failed to delete suite');
93
+ }
94
+
95
+ // ── Test Cases ───────────────────────────────────────────────────────────────
96
+
97
+ export async function fetchTestCase(id) {
98
+ const res = await fetch(`${API_BASE}/test-cases/${id}`, { headers: authHeaders() });
99
+ if (!res.ok) throw new Error('Test case not found');
100
+ const data = await res.json();
101
+ return data.testCase;
102
+ }
103
+
104
+ export async function createTestCase({ suiteId, title, description, priority }) {
105
+ const res = await fetch(`${API_BASE}/test-cases`, {
106
+ method: 'POST',
107
+ headers: authHeaders(),
108
+ body: JSON.stringify({ suiteId, title, description, priority })
109
+ });
110
+ const data = await res.json();
111
+ if (!res.ok) throw new Error(data.error ?? 'Failed to create test case');
112
+ return data.testCase;
113
+ }
114
+
115
+ export async function updateTestCase(id, { title, description, priority }) {
116
+ const res = await fetch(`${API_BASE}/test-cases/${id}`, {
117
+ method: 'PUT',
118
+ headers: authHeaders(),
119
+ body: JSON.stringify({ title, description, priority })
120
+ });
121
+ const data = await res.json();
122
+ if (!res.ok) throw new Error(data.error ?? 'Failed to update test case');
123
+ return data.testCase;
124
+ }
125
+
126
+ export async function saveSteps(caseId, steps) {
127
+ const res = await fetch(`${API_BASE}/test-cases/${caseId}/steps`, {
128
+ method: 'PUT',
129
+ headers: authHeaders(),
130
+ body: JSON.stringify({ steps })
131
+ });
132
+ const data = await res.json();
133
+ if (!res.ok) throw new Error(data.error ?? 'Failed to save steps');
134
+ return data.steps;
135
+ }
136
+
137
+ export async function deleteTestCase(id) {
138
+ const res = await fetch(`${API_BASE}/test-cases/${id}`, {
139
+ method: 'DELETE',
140
+ headers: authHeaders()
141
+ });
142
+ if (!res.ok) throw new Error('Failed to delete test case');
143
+ }
144
+
145
+ // ── Test Runs ────────────────────────────────────────────────────────────────
146
+
147
+ export async function fetchRuns({ page = 1, limit = REPO_PAGE_SIZE, sortBy, sortOrder } = {}) {
148
+ const params = new URLSearchParams({ page, limit });
149
+ if (sortBy) params.set('sortBy', sortBy);
150
+ if (sortOrder) params.set('sortOrder', sortOrder);
151
+ const res = await fetch(`${API_BASE}/test-runs?${params}`, { headers: authHeaders() });
152
+ if (!res.ok) throw new Error('Failed to fetch test runs');
153
+ return res.json(); // { runs, total }
154
+ }
155
+
156
+ export async function fetchRun(id) {
157
+ const res = await fetch(`${API_BASE}/test-runs/${id}`, { headers: authHeaders() });
158
+ if (!res.ok) throw new Error('Test run not found');
159
+ const data = await res.json();
160
+ return data.run;
161
+ }
162
+
163
+ export async function createRun({ title, caseIds }) {
164
+ const res = await fetch(`${API_BASE}/test-runs`, {
165
+ method: 'POST',
166
+ headers: authHeaders(),
167
+ body: JSON.stringify({ title, caseIds })
168
+ });
169
+ const data = await res.json();
170
+ if (!res.ok) throw new Error(data.error ?? 'Failed to create test run');
171
+ return data.run;
172
+ }
173
+
174
+ export async function updateRun(id, { title, status, caseIds }) {
175
+ const res = await fetch(`${API_BASE}/test-runs/${id}`, {
176
+ method: 'PUT',
177
+ headers: authHeaders(),
178
+ body: JSON.stringify({ title, status, caseIds })
179
+ });
180
+ const data = await res.json();
181
+ if (!res.ok) throw new Error(data.error ?? 'Failed to update test run');
182
+ return data.run;
183
+ }
184
+
185
+ export async function duplicateRun(id) {
186
+ const res = await fetch(`${API_BASE}/test-runs/${id}/duplicate`, {
187
+ method: 'POST',
188
+ headers: authHeaders()
189
+ });
190
+ const data = await res.json();
191
+ if (!res.ok) throw new Error(data.error ?? 'Failed to duplicate run');
192
+ return data.run;
193
+ }
194
+
195
+ export async function deleteRun(id) {
196
+ const res = await fetch(`${API_BASE}/test-runs/${id}`, {
197
+ method: 'DELETE',
198
+ headers: authHeaders()
199
+ });
200
+ if (!res.ok) throw new Error('Failed to delete test run');
201
+ }
202
+
203
+ export async function recordEntryResult(entryId, { status, notes }) {
204
+ const res = await fetch(`${API_BASE}/test-runs/entries/${entryId}/result`, {
205
+ method: 'POST',
206
+ headers: authHeaders(),
207
+ body: JSON.stringify({ status, notes })
208
+ });
209
+ const data = await res.json();
210
+ if (!res.ok) throw new Error(data.error ?? 'Failed to record result');
211
+ return data.entry;
212
+ }
213
+
214
+ export async function assignEntry(entryId, userId) {
215
+ const res = await fetch(`${API_BASE}/test-runs/entries/${entryId}/assign`, {
216
+ method: 'PUT',
217
+ headers: authHeaders(),
218
+ body: JSON.stringify({ userId: userId ?? null })
219
+ });
220
+ const data = await res.json();
221
+ if (!res.ok) throw new Error(data.error ?? 'Failed to assign entry');
222
+ return data.entry;
223
+ }
224
+
225
+ export async function fetchMembers() {
226
+ const res = await fetch(`${API_BASE}/users/members`, { headers: authHeaders() });
227
+ if (!res.ok) throw new Error('Failed to fetch members');
228
+ const data = await res.json();
229
+ return data.users;
230
+ }
231
+
232
+ // ── Prefixes ─────────────────────────────────────────────────────────────────
233
+
234
+ export async function fetchPrefixes() {
235
+ const res = await fetch(`${API_BASE}/settings/test-prefixes`, { headers: authHeaders() });
236
+ if (!res.ok) throw new Error('Failed to fetch prefixes');
237
+ return res.json();
238
+ }
239
+
240
+ export async function savePrefixes({ testCasePrefix, testSuitePrefix }) {
241
+ const res = await fetch(`${API_BASE}/settings/test-prefixes`, {
242
+ method: 'POST',
243
+ headers: authHeaders(),
244
+ body: JSON.stringify({ testCasePrefix, testSuitePrefix })
245
+ });
246
+ return res.json();
247
+ }
248
+
249
+ export async function migratePrefixes({ testCasePrefix, testSuitePrefix }) {
250
+ const res = await fetch(`${API_BASE}/settings/test-prefixes/migrate`, {
251
+ method: 'POST',
252
+ headers: authHeaders(),
253
+ body: JSON.stringify({ testCasePrefix, testSuitePrefix })
254
+ });
255
+ return res.json();
256
+ }