plum-e2e 1.3.7 → 2.2.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.
- package/README.md +111 -3
- package/backend/app.js +5 -0
- package/backend/config/scripts/create-test.mjs +172 -0
- package/backend/config/scripts/generate-report.js +2 -1
- package/backend/lib/runnerProcess.js +50 -4
- package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
- package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
- package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
- package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
- package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
- package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
- package/backend/middleware/jwtAuth.js +33 -0
- package/backend/middleware/requireAdmin.js +25 -0
- package/backend/package.json +2 -0
- package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
- package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
- package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
- package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
- package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
- package/backend/prisma/schema.prisma +123 -10
- package/backend/routes/auth.routes.js +96 -0
- package/backend/routes/node.routes.js +9 -0
- package/backend/routes/runners.routes.js +10 -0
- package/backend/routes/settings.routes.js +71 -8
- package/backend/routes/test-cases.routes.js +80 -0
- package/backend/routes/test-runs.routes.js +122 -0
- package/backend/routes/test-suites.routes.js +92 -0
- package/backend/routes/users.routes.js +67 -0
- package/backend/scripts/create-test.js +7 -6
- package/backend/scripts/manage-runners.mjs +49 -8
- package/backend/server.js +22 -1
- package/backend/services/cronService.js +91 -7
- package/backend/services/notificationService.js +163 -0
- package/backend/services/reportService.js +96 -4
- package/backend/services/settingsService.js +46 -2
- package/backend/services/testCaseService.js +139 -0
- package/backend/services/testRunService.js +203 -0
- package/backend/services/testSuiteService.js +191 -0
- package/backend/services/userService.js +114 -0
- package/backend/websockets/socketHandler.js +96 -7
- package/bin/plum.js +105 -9
- package/frontend/src/lib/api/auth.js +69 -0
- package/frontend/src/lib/api/repository.js +256 -0
- package/frontend/src/lib/api/schedules.js +5 -1
- package/frontend/src/lib/api/settings.js +15 -0
- package/frontend/src/lib/api/users.js +52 -0
- package/frontend/src/lib/components/layout/Nav.svelte +116 -4
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
- package/frontend/src/lib/components/ui/Modal.svelte +8 -1
- package/frontend/src/lib/constants.js +2 -0
- package/frontend/src/lib/stores/auth.js +60 -0
- package/frontend/src/lib/stores/runner.js +11 -2
- package/frontend/src/routes/+layout.svelte +32 -4
- package/frontend/src/routes/+page.svelte +1 -1
- package/frontend/src/routes/login/+page.svelte +209 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
- package/frontend/src/routes/settings/+page.svelte +677 -6
- package/frontend/src/routes/setup/+page.svelte +249 -0
- package/frontend/src/routes/test-repository/+page.svelte +1379 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -30,6 +30,8 @@ export async function saveCronJob({
|
|
|
30
30
|
workers,
|
|
31
31
|
browser,
|
|
32
32
|
runnerIds,
|
|
33
|
+
notifyDiscord,
|
|
34
|
+
notifySlack,
|
|
33
35
|
isEditing,
|
|
34
36
|
editTaskName
|
|
35
37
|
}) {
|
|
@@ -47,7 +49,9 @@ export async function saveCronJob({
|
|
|
47
49
|
tags: formattedTags,
|
|
48
50
|
workers,
|
|
49
51
|
browser,
|
|
50
|
-
runnerIds
|
|
52
|
+
runnerIds,
|
|
53
|
+
notifyDiscord: notifyDiscord ?? false,
|
|
54
|
+
notifySlack: notifySlack ?? false
|
|
51
55
|
})
|
|
52
56
|
});
|
|
53
57
|
return res.json();
|
|
@@ -46,3 +46,18 @@ export async function importBackup(data) {
|
|
|
46
46
|
});
|
|
47
47
|
return res.json();
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export async function fetchIntegrations() {
|
|
51
|
+
const res = await fetch(`${API_BASE}/settings/integrations`);
|
|
52
|
+
if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
53
|
+
return res.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveIntegrations({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl }) {
|
|
57
|
+
const res = await fetch(`${API_BASE}/settings/integrations`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl })
|
|
61
|
+
});
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
@@ -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: '
|
|
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
|
-
|
|
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={
|
|
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;
|