plum-e2e 1.3.6 → 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 (47) 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/runnerService.js +16 -1
  22. package/backend/services/settingsService.js +18 -2
  23. package/backend/services/testCaseService.js +139 -0
  24. package/backend/services/testRunService.js +203 -0
  25. package/backend/services/testSuiteService.js +191 -0
  26. package/backend/services/userService.js +114 -0
  27. package/backend/websockets/socketHandler.js +19 -6
  28. package/bin/plum.js +105 -9
  29. package/frontend/src/lib/api/auth.js +69 -0
  30. package/frontend/src/lib/api/repository.js +256 -0
  31. package/frontend/src/lib/api/users.js +52 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  33. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  34. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  35. package/frontend/src/lib/constants.js +2 -0
  36. package/frontend/src/lib/stores/auth.js +60 -0
  37. package/frontend/src/lib/stores/runner.js +9 -2
  38. package/frontend/src/routes/+layout.svelte +32 -4
  39. package/frontend/src/routes/+page.svelte +1 -1
  40. package/frontend/src/routes/login/+page.svelte +209 -0
  41. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -1
  42. package/frontend/src/routes/settings/+page.svelte +586 -5
  43. package/frontend/src/routes/setup/+page.svelte +249 -0
  44. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  45. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  46. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  47. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,52 @@
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
+ import { auth } from '$lib/stores/auth';
20
+
21
+ function authHeaders() {
22
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.getToken()}` };
23
+ }
24
+
25
+ export async function fetchUsers() {
26
+ const res = await fetch(`${API_BASE}/users`, { headers: authHeaders() });
27
+ if (!res.ok) throw new Error('Failed to fetch users');
28
+ const data = await res.json();
29
+ return data.users;
30
+ }
31
+
32
+ export async function createUser({ name, email, password, role = 'user' }) {
33
+ const res = await fetch(`${API_BASE}/users`, {
34
+ method: 'POST',
35
+ headers: authHeaders(),
36
+ body: JSON.stringify({ name, email, password, role })
37
+ });
38
+ const data = await res.json();
39
+ if (!res.ok) throw new Error(data.error ?? 'Failed to create user');
40
+ return data.user;
41
+ }
42
+
43
+ export async function deleteUser(id) {
44
+ const res = await fetch(`${API_BASE}/users/${id}`, {
45
+ method: 'DELETE',
46
+ headers: authHeaders()
47
+ });
48
+ if (!res.ok) {
49
+ const data = await res.json();
50
+ throw new Error(data.error ?? 'Failed to delete user');
51
+ }
52
+ }
@@ -18,13 +18,25 @@
18
18
  <script>
19
19
  import { page } from '$app/stores';
20
20
  import { slide } from 'svelte/transition';
21
+ import { onMount } from 'svelte';
22
+ import { auth } from '$lib/stores/auth';
23
+ import { goto } from '$app/navigation';
24
+ import { fetchProject } from '$lib/api/settings';
21
25
 
22
26
  let menuOpen = false;
27
+ let project = { name: '', logoUrl: '' };
28
+
29
+ onMount(async () => {
30
+ try {
31
+ project = await fetchProject();
32
+ } catch {}
33
+ });
23
34
 
24
35
  const links = [
25
- { href: '/', label: 'Run Tests' },
36
+ { href: '/', label: 'Automated Tests' },
26
37
  { href: '/reports', label: 'Reports' },
27
- { href: '/scheduled-tests', label: 'Scheduled' }
38
+ { href: '/scheduled-tests', label: 'Scheduled' },
39
+ { href: '/test-repository', label: 'Test Repository', sep: true }
28
40
  ];
29
41
 
30
42
  function closeMenu() {
@@ -40,13 +52,34 @@
40
52
 
41
53
  <div class="links">
42
54
  {#each links as link}
43
- <a href={link.href} class="link" class:active={$page.url.pathname === link.href}>
55
+ {#if link.sep}
56
+ <span class="nav-sep" aria-hidden="true"></span>
57
+ {/if}
58
+ <a
59
+ href={link.href}
60
+ class="link"
61
+ class:repo={link.sep}
62
+ class:active={link.href === '/'
63
+ ? $page.url.pathname === '/'
64
+ : $page.url.pathname.startsWith(link.href)}
65
+ >
44
66
  {link.label}
45
67
  </a>
46
68
  {/each}
47
69
  </div>
48
70
 
49
71
  <div class="actions">
72
+ {#if project.name}
73
+ <div class="project-card">
74
+ {#if project.logoUrl}
75
+ <img src={project.logoUrl} alt="" class="project-logo" />
76
+ {/if}
77
+ <span class="project-name">{project.name}</span>
78
+ </div>
79
+ {/if}
80
+ {#if $auth.user}
81
+ <span class="nav-user">{$auth.user.name}</span>
82
+ {/if}
50
83
  <a
51
84
  href="/settings"
52
85
  class="settings-btn"
@@ -87,10 +120,15 @@
87
120
  {#if menuOpen}
88
121
  <div class="mobile-menu" transition:slide={{ duration: 200 }}>
89
122
  {#each links as link}
123
+ {#if link.sep}
124
+ <hr class="mobile-sep" />
125
+ {/if}
90
126
  <a
91
127
  href={link.href}
92
128
  class="mobile-link"
93
- class:active={$page.url.pathname === link.href}
129
+ class:active={link.href === '/'
130
+ ? $page.url.pathname === '/'
131
+ : $page.url.pathname.startsWith(link.href)}
94
132
  on:click={closeMenu}
95
133
  >
96
134
  {link.label}
@@ -150,6 +188,36 @@
150
188
  color: var(--text);
151
189
  }
152
190
 
191
+ /* Project card */
192
+ .project-card {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 0.4rem;
196
+ padding: 0.25rem 0.6rem 0.25rem 0.5rem;
197
+ border: 1px solid var(--border);
198
+ border-radius: var(--radius-sm);
199
+ background: var(--bg-elevated);
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ .project-logo {
204
+ width: 16px;
205
+ height: 16px;
206
+ object-fit: contain;
207
+ border-radius: 2px;
208
+ flex-shrink: 0;
209
+ }
210
+
211
+ .project-name {
212
+ font-size: 0.78rem;
213
+ font-weight: 500;
214
+ color: var(--text);
215
+ max-width: 140px;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ }
220
+
153
221
  /* Desktop links */
154
222
  .links {
155
223
  display: flex;
@@ -179,6 +247,29 @@
179
247
  background: var(--accent-soft);
180
248
  }
181
249
 
250
+ .nav-sep {
251
+ display: block;
252
+ width: 1px;
253
+ height: 18px;
254
+ background: var(--border);
255
+ margin: 0 0.375rem;
256
+ flex-shrink: 0;
257
+ align-self: center;
258
+ }
259
+
260
+ .link.repo {
261
+ border: 1px solid var(--border);
262
+ padding: 0.3rem 0.75rem;
263
+ }
264
+
265
+ .link.repo:hover {
266
+ border-color: var(--text-muted);
267
+ }
268
+
269
+ .link.repo.active {
270
+ border-color: var(--accent);
271
+ }
272
+
182
273
  /* Actions */
183
274
  .actions {
184
275
  display: flex;
@@ -187,6 +278,21 @@
187
278
  margin-left: auto;
188
279
  }
189
280
 
281
+ .nav-user {
282
+ font-size: 0.8125rem;
283
+ color: var(--text-muted);
284
+ max-width: 120px;
285
+ overflow: hidden;
286
+ text-overflow: ellipsis;
287
+ white-space: nowrap;
288
+ }
289
+
290
+ @media (max-width: 640px) {
291
+ .nav-user {
292
+ display: none;
293
+ }
294
+ }
295
+
190
296
  /* Settings gear icon */
191
297
  .settings-btn {
192
298
  display: flex;
@@ -288,6 +394,12 @@
288
394
  background: var(--accent-soft);
289
395
  }
290
396
 
397
+ .mobile-sep {
398
+ border: none;
399
+ border-top: 1px solid var(--border);
400
+ margin: 0.375rem 0.75rem;
401
+ }
402
+
291
403
  @media (max-width: 640px) {
292
404
  .links {
293
405
  display: none;