@podosoft/podokit 1.6.0 → 1.6.1
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/dist/deploy-checks.js +3 -3
- package/dist/deploy-schema.d.ts +7 -3
- package/dist/deploy-schema.js +16 -5
- package/dist/templates/fullstack/AGENTS.md +3 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +2 -1
- package/dist/templates/fullstack/tests/README.md +20 -10
- package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
- package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/package.json +2 -2
package/dist/deploy-checks.js
CHANGED
|
@@ -123,9 +123,9 @@ function connectionBudget(profile, modules) {
|
|
|
123
123
|
perProcessTerms.push(`DB_AUTH_POOL_MAX ${authPool}`);
|
|
124
124
|
perProcess += authPool;
|
|
125
125
|
}
|
|
126
|
-
for (const pool of postgres.appPools ?? []) {
|
|
127
|
-
const size = poolSize(config, pool.sizeKey, 0);
|
|
128
|
-
perProcessTerms.push(`${
|
|
126
|
+
for (const [index, pool] of (postgres.appPools ?? []).entries()) {
|
|
127
|
+
const [label, size] = "sizeKey" in pool ? [pool.sizeKey, poolSize(config, pool.sizeKey, 0)] : [`appPools[${index}]`, pool.size];
|
|
128
|
+
perProcessTerms.push(`${label} ${size}${pool.extraConnections ? ` + ${pool.extraConnections}` : ""}`);
|
|
129
129
|
perProcess += size + pool.extraConnections;
|
|
130
130
|
}
|
|
131
131
|
const worker = profile.workloads.worker;
|
package/dist/deploy-schema.d.ts
CHANGED
|
@@ -70,14 +70,18 @@ export declare function parseReleaseProfile(release: Record<string, unknown>): R
|
|
|
70
70
|
export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
|
|
71
71
|
/**
|
|
72
72
|
* An application-owned PostgreSQL pool each API or worker process opens beside
|
|
73
|
-
* PodoKit's own
|
|
73
|
+
* PodoKit's own. Either `sizeKey` names the runtimeConfig key that caps it, or
|
|
74
|
+
* `size` is the cap the application's code sets, such as a driver's default.
|
|
74
75
|
* `extraConnections` counts connections it opens beyond that cap, such as one to
|
|
75
76
|
* check a server that stopped answering.
|
|
76
77
|
*/
|
|
77
|
-
export
|
|
78
|
+
export type AppConnectionPool = {
|
|
78
79
|
sizeKey: string;
|
|
79
80
|
extraConnections: number;
|
|
80
|
-
}
|
|
81
|
+
} | {
|
|
82
|
+
size: number;
|
|
83
|
+
extraConnections: number;
|
|
84
|
+
};
|
|
81
85
|
/** What the connection budget needs to know about the database, beyond its mode. */
|
|
82
86
|
export interface PostgresBudgetProfile {
|
|
83
87
|
/** The server's max_connections; PodoKit sets it on a database it runs. */
|
package/dist/deploy-schema.js
CHANGED
|
@@ -270,7 +270,22 @@ function parsePostgresBudget(value, field, runtimeConfig) {
|
|
|
270
270
|
const entryField = `${field}.appPools[${index}]`;
|
|
271
271
|
if (!isRecord(entry))
|
|
272
272
|
throw new Error(`Deployment profile field "${entryField}" must be an object.`);
|
|
273
|
-
assertOnlyKeys(entry, ["sizeKey", "extraConnections"], entryField);
|
|
273
|
+
assertOnlyKeys(entry, ["sizeKey", "size", "extraConnections"], entryField);
|
|
274
|
+
if ((entry.sizeKey === undefined) === (entry.size === undefined)) {
|
|
275
|
+
throw new Error(`Deployment profile field "${entryField}" must set either sizeKey, the runtimeConfig key that caps the pool, ` +
|
|
276
|
+
"or size, the cap the application sets itself.");
|
|
277
|
+
}
|
|
278
|
+
const extraConnections = entry.extraConnections ?? 0;
|
|
279
|
+
if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
|
|
280
|
+
throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
|
|
281
|
+
}
|
|
282
|
+
if (entry.size !== undefined) {
|
|
283
|
+
const size = entry.size;
|
|
284
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size < 1 || size > MAX_POSTGRES_CONNECTIONS) {
|
|
285
|
+
throw new Error(`Deployment profile field "${entryField}.size" must be an integer from 1 to ${MAX_POSTGRES_CONNECTIONS}.`);
|
|
286
|
+
}
|
|
287
|
+
return { size, extraConnections };
|
|
288
|
+
}
|
|
274
289
|
const sizeKey = requiredString(entry, "sizeKey");
|
|
275
290
|
if (!/^[A-Z][A-Z0-9_]*$/.test(sizeKey) || PODOKIT_POOL_KEYS.includes(sizeKey) || seen.has(sizeKey)) {
|
|
276
291
|
throw new Error(`Deployment profile field "${entryField}.sizeKey" must name another pool's runtimeConfig key, once; ` +
|
|
@@ -280,10 +295,6 @@ function parsePostgresBudget(value, field, runtimeConfig) {
|
|
|
280
295
|
if (!/^[1-9][0-9]*$/.test(runtimeConfig[sizeKey] ?? "")) {
|
|
281
296
|
throw new Error(`Deployment profile field "${entryField}.sizeKey" names ${sizeKey}, which runtimeConfig must set to a positive integer.`);
|
|
282
297
|
}
|
|
283
|
-
const extraConnections = entry.extraConnections ?? 0;
|
|
284
|
-
if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
|
|
285
|
-
throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
|
|
286
|
-
}
|
|
287
298
|
return { sizeKey, extraConnections };
|
|
288
299
|
});
|
|
289
300
|
}
|
|
@@ -105,7 +105,9 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
105
105
|
- e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
|
|
106
106
|
workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
|
|
107
107
|
it changes state other tests rely on (a server-wide setting, a seeded account's profile);
|
|
108
|
-
tagged tests run alone after the rest
|
|
108
|
+
tagged tests run alone after the rest, in a second Playwright run that `test:e2e` starts
|
|
109
|
+
even when the first one failed, so run the suite through `test:e2e`, not
|
|
110
|
+
`bunx playwright test`, on more workers. Give each test its own mail and SMS recipients and
|
|
109
111
|
never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`. The managed specs
|
|
110
112
|
read the app's first screen, public pages, and seeded accounts from the owned
|
|
111
113
|
`tests/helpers/app-paths.ts` (and `app-accounts.ts` with admin-dashboard): change those
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { createApp } from "../src/app";
|
|
4
4
|
import { validateEnv } from "../src/config/env.validation";
|
|
5
|
+
import { PROVIDERS } from "../src/config/providers";
|
|
5
6
|
import {
|
|
6
7
|
assertApiContract,
|
|
7
8
|
assertApplicationRoutes,
|
|
@@ -30,7 +31,7 @@ try {
|
|
|
30
31
|
}
|
|
31
32
|
const document: unknown = await response.json();
|
|
32
33
|
const modules = manifest.modules.map((module) => module.name);
|
|
33
|
-
assertApiContract(document, manifest.template, modules);
|
|
34
|
+
assertApiContract(document, manifest.template, modules, PROVIDERS);
|
|
34
35
|
assertApplicationRoutes(document, app.routes, APP_DOCUMENTED_ROUTES);
|
|
35
36
|
process.stdout.write(
|
|
36
37
|
`Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}, ` +
|
|
@@ -4,14 +4,17 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
const BUILD = join(__dirname, "../scripts/build.mjs");
|
|
7
|
-
|
|
7
|
+
// The application's own build.extensions.mjs names entry points this temporary
|
|
8
|
+
// layout does not contain, so each build starts without application targets. A
|
|
9
|
+
// test that needs some passes its own scripts/build.extensions.mjs.
|
|
10
|
+
const NO_APP_TARGETS = "export const appBuildTargets = [];\n";
|
|
8
11
|
|
|
9
12
|
/** Builds an API layout made of `files` with the app's build script. */
|
|
10
13
|
async function build(files: Record<string, string>): Promise<{ root: string; code: number; stderr: string }> {
|
|
11
14
|
const root = mkdtempSync(join(tmpdir(), "api-build-"));
|
|
12
15
|
mkdirSync(join(root, "scripts"));
|
|
13
16
|
cpSync(BUILD, join(root, "scripts/build.mjs"));
|
|
14
|
-
|
|
17
|
+
writeFileSync(join(root, "scripts/build.extensions.mjs"), NO_APP_TARGETS);
|
|
15
18
|
for (const [path, content] of Object.entries(files)) {
|
|
16
19
|
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
17
20
|
writeFileSync(join(root, path), content);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { LOCAL_PROVIDER_SELECTIONS, SERVER_PROVIDER_SELECTIONS } from "@podosoft/podokit-runtime";
|
|
2
3
|
import {
|
|
3
4
|
assertApiContract,
|
|
4
5
|
assertApplicationRoutes,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
|
|
13
14
|
describe("API contract inventory", () => {
|
|
14
15
|
it("covers the core, Todo, module, and Better Auth routes", () => {
|
|
15
|
-
const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"]);
|
|
16
|
+
const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"], SERVER_PROVIDER_SELECTIONS);
|
|
16
17
|
|
|
17
18
|
expect(expected).toContain("GET /health/ready");
|
|
18
19
|
expect(expected).toContain("PATCH /todos/{id}");
|
|
@@ -23,7 +24,7 @@ describe("API contract inventory", () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
it("uses one structural parameter name for every public blog post route", () => {
|
|
26
|
-
const expected = expectedApiRoutes("fullstack", ["blog"]);
|
|
27
|
+
const expected = expectedApiRoutes("fullstack", ["blog"], SERVER_PROVIDER_SELECTIONS);
|
|
27
28
|
|
|
28
29
|
expect(expected).toContain("GET /blog/{postRef}");
|
|
29
30
|
expect(expected).toContain("PATCH /blog/{postRef}");
|
|
@@ -32,15 +33,53 @@ describe("API contract inventory", () => {
|
|
|
32
33
|
expect(expected).not.toContain("PATCH /blog/{id}");
|
|
33
34
|
});
|
|
34
35
|
|
|
36
|
+
it("expects a provider module's routes only while its provider is selected", () => {
|
|
37
|
+
const modules = [
|
|
38
|
+
"object-storage-s3",
|
|
39
|
+
"object-storage-local",
|
|
40
|
+
"redis",
|
|
41
|
+
"cache-memory",
|
|
42
|
+
"bullmq",
|
|
43
|
+
"jobs-local",
|
|
44
|
+
];
|
|
45
|
+
const server = expectedApiRoutes("fullstack", modules, SERVER_PROVIDER_SELECTIONS);
|
|
46
|
+
const local = expectedApiRoutes("fullstack", modules, LOCAL_PROVIDER_SELECTIONS);
|
|
47
|
+
|
|
48
|
+
expect(server).toContain("PUT /storage/{key}");
|
|
49
|
+
expect(server).not.toContain("GET /files/content/*");
|
|
50
|
+
expect(server).toContain("PUT /cache/{key}");
|
|
51
|
+
expect(server).toContain("POST /jobs");
|
|
52
|
+
expect(local).toContain("PUT /storage/{key}");
|
|
53
|
+
expect(local).toContain("GET /files/content/*");
|
|
54
|
+
expect(local).not.toContain("PUT /cache/{key}");
|
|
55
|
+
expect(local).not.toContain("GET /cache/{key}");
|
|
56
|
+
expect(local).toContain("GET /jobs/{id}");
|
|
57
|
+
|
|
58
|
+
const s3Document = {
|
|
59
|
+
paths: {
|
|
60
|
+
"/health": { get: {} },
|
|
61
|
+
"/health/ready": { get: {} },
|
|
62
|
+
"/storage/{key}": { get: {}, put: {} },
|
|
63
|
+
"/storage/{key}/presigned": { get: {} },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const storage = ["object-storage-s3", "object-storage-local"];
|
|
67
|
+
expect(() => assertApiContract(s3Document, "fullstack", storage, SERVER_PROVIDER_SELECTIONS)).not.toThrow();
|
|
68
|
+
expect(() => assertApiContract(s3Document, "fullstack", storage, {
|
|
69
|
+
...SERVER_PROVIDER_SELECTIONS,
|
|
70
|
+
"object-storage": "local",
|
|
71
|
+
})).toThrow("GET /files/content/*");
|
|
72
|
+
});
|
|
73
|
+
|
|
35
74
|
it("normalizes OpenAPI operations and reports an exact missing route", () => {
|
|
36
75
|
const document = { paths: { "/health": { get: {} }, "/health/ready": { get: {} } } };
|
|
37
76
|
|
|
38
77
|
expect(documentedApiRoutes(document)).toEqual(new Set(["GET /health", "GET /health/ready"]));
|
|
39
|
-
expect(missingApiRoutes(document, "fullstack", ["redis"])).toEqual([
|
|
78
|
+
expect(missingApiRoutes(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toEqual([
|
|
40
79
|
"GET /cache/{key}",
|
|
41
80
|
"PUT /cache/{key}",
|
|
42
81
|
]);
|
|
43
|
-
expect(() => assertApiContract(document, "fullstack", ["redis"])).toThrow(
|
|
82
|
+
expect(() => assertApiContract(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toThrow(
|
|
44
83
|
"OpenAPI contract is missing routes",
|
|
45
84
|
);
|
|
46
85
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ProviderCapability, ProviderSelections } from "@podosoft/podokit-runtime";
|
|
2
|
+
|
|
1
3
|
const OPERATIONS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
|
|
2
4
|
|
|
3
5
|
const CORE_ROUTES = [
|
|
@@ -81,6 +83,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
81
83
|
"DELETE /files/uploads/{id}",
|
|
82
84
|
],
|
|
83
85
|
"job-progress": ["POST /progress"],
|
|
86
|
+
"jobs-local": ["POST /jobs", "GET /jobs/{id}"],
|
|
84
87
|
"object-storage-local": [
|
|
85
88
|
"GET /files/content/*",
|
|
86
89
|
"PUT /storage/{key}",
|
|
@@ -96,6 +99,22 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
96
99
|
sse: ["GET /events/stream", "POST /events"],
|
|
97
100
|
};
|
|
98
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The provider each provider module implements. Such a module registers its
|
|
104
|
+
* routes only while its provider is selected, so an app may keep, say, both
|
|
105
|
+
* object-storage modules installed and serve the routes of one.
|
|
106
|
+
*/
|
|
107
|
+
export const MODULE_PROVIDERS: Readonly<Record<string, readonly [ProviderCapability, string]>> = {
|
|
108
|
+
bullmq: ["jobs", "bullmq"],
|
|
109
|
+
"cache-memory": ["cache", "memory"],
|
|
110
|
+
"events-memory": ["events", "memory"],
|
|
111
|
+
"events-redis": ["events", "redis"],
|
|
112
|
+
"jobs-local": ["jobs", "local"],
|
|
113
|
+
"object-storage-local": ["object-storage", "local"],
|
|
114
|
+
"object-storage-s3": ["object-storage", "s3"],
|
|
115
|
+
redis: ["cache", "redis"],
|
|
116
|
+
};
|
|
117
|
+
|
|
99
118
|
interface RuntimeRoute {
|
|
100
119
|
method?: string;
|
|
101
120
|
path: string;
|
|
@@ -195,10 +214,20 @@ export function documentedApiRoutes(document: unknown): Set<string> {
|
|
|
195
214
|
return routes;
|
|
196
215
|
}
|
|
197
216
|
|
|
198
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The routes the template and the installed modules serve. A provider module
|
|
219
|
+
* counts only when `providers`, the app's PROVIDERS, selects its provider.
|
|
220
|
+
*/
|
|
221
|
+
export function expectedApiRoutes(
|
|
222
|
+
template: string,
|
|
223
|
+
modules: string[],
|
|
224
|
+
providers: Readonly<ProviderSelections>,
|
|
225
|
+
): Set<string> {
|
|
199
226
|
const routes = new Set(CORE_ROUTES);
|
|
200
227
|
for (const route of TEMPLATE_ROUTES[template] ?? []) routes.add(route);
|
|
201
228
|
for (const module of modules) {
|
|
229
|
+
const provider = MODULE_PROVIDERS[module];
|
|
230
|
+
if (provider && providers[provider[0]] !== provider[1]) continue;
|
|
202
231
|
for (const route of MODULE_ROUTES[module] ?? []) routes.add(route);
|
|
203
232
|
}
|
|
204
233
|
return routes;
|
|
@@ -208,15 +237,21 @@ export function missingApiRoutes(
|
|
|
208
237
|
document: unknown,
|
|
209
238
|
template: string,
|
|
210
239
|
modules: string[],
|
|
240
|
+
providers: Readonly<ProviderSelections>,
|
|
211
241
|
): string[] {
|
|
212
242
|
const documented = documentedApiRoutes(document);
|
|
213
|
-
return [...expectedApiRoutes(template, modules)]
|
|
243
|
+
return [...expectedApiRoutes(template, modules, providers)]
|
|
214
244
|
.filter((route) => !documented.has(route))
|
|
215
245
|
.sort();
|
|
216
246
|
}
|
|
217
247
|
|
|
218
|
-
export function assertApiContract(
|
|
219
|
-
|
|
248
|
+
export function assertApiContract(
|
|
249
|
+
document: unknown,
|
|
250
|
+
template: string,
|
|
251
|
+
modules: string[],
|
|
252
|
+
providers: Readonly<ProviderSelections>,
|
|
253
|
+
): void {
|
|
254
|
+
const missing = missingApiRoutes(document, template, modules, providers);
|
|
220
255
|
if (missing.length > 0) {
|
|
221
256
|
throw new Error(`OpenAPI contract is missing routes:\n${missing.join("\n")}`);
|
|
222
257
|
}
|
|
@@ -32,7 +32,8 @@ Review these fields before planning:
|
|
|
32
32
|
- `workloads`: API/web replicas and resources plus an optional worker
|
|
33
33
|
- `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage.
|
|
34
34
|
PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
|
|
35
|
-
application opens itself
|
|
35
|
+
application opens itself, each sized by a `runtimeConfig` key (`sizeKey`) or a
|
|
36
|
+
fixed `size` when the code sets it; the doctor and the plan check the connection budget
|
|
36
37
|
against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
|
|
37
38
|
- `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
|
|
38
39
|
- `runtimeConfig`: non-sensitive environment values only
|
|
@@ -45,16 +45,26 @@ Tag a test when it changes a server-wide setting (a feature flag, the
|
|
|
45
45
|
authentication configuration such as SMTP, social providers, or sign-up
|
|
46
46
|
approval, the site settings, or a module's settings) or the profile of a seeded
|
|
47
47
|
account. Each project has an `<name>-exclusive` twin, such as `ui-exclusive`,
|
|
48
|
-
that runs only the tagged tests, one at a time.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
that runs only the tagged tests, one at a time. Never empty the shared mail
|
|
49
|
+
catcher or SMS sink in a test: send to an address or number of the test's own
|
|
50
|
+
instead.
|
|
51
|
+
|
|
52
|
+
The `test:e2e` scripts run `run-e2e.ts`, which hands its arguments (a file,
|
|
53
|
+
`--grep`, `--project`) to Playwright. With more than one worker it runs
|
|
54
|
+
Playwright twice: first every test not tagged `@exclusive` on those workers,
|
|
55
|
+
then the tagged tests on one worker, even when a test failed in the first run.
|
|
56
|
+
The suite fails when either run fails. The second run reports to
|
|
57
|
+
`playwright-report/exclusive` and `test-results/exclusive`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bunx playwright show-report playwright-report/exclusive
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A plain `bunx playwright test` with `E2E_WORKERS` above 1 makes one run in which
|
|
64
|
+
the twins depend on every other test: a failing test there leaves the exclusive
|
|
65
|
+
tests not run, and selecting an exclusive test runs every other test first. Use
|
|
66
|
+
`{{rootRun}} test:e2e` on more workers, and do not combine `--no-deps` with
|
|
67
|
+
`E2E_WORKERS` above 1.
|
|
58
68
|
|
|
59
69
|
Application-owned projects in `playwright.projects.cjs` get twins too. A project
|
|
60
70
|
that another project depends on is a setup project and keeps a single copy.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The administrator the admin-dashboard Playwright setup seeds, with the address
|
|
6
|
+
* and password `accounts.ts` reads from `E2E_*` variables and the application's
|
|
7
|
+
* `app-accounts.ts`, or null in an app without that module. Specs of modules
|
|
8
|
+
* that do not require admin-dashboard sign in with this instead of importing
|
|
9
|
+
* `accounts.ts`, which only admin-dashboard installs.
|
|
10
|
+
*/
|
|
11
|
+
export async function seededAdmin(): Promise<{ email: string; password: string } | null> {
|
|
12
|
+
if (!existsSync(join(__dirname, "accounts.ts"))) return null;
|
|
13
|
+
const { ADMIN } = await import("./accounts");
|
|
14
|
+
return { email: ADMIN.email, password: ADMIN.password };
|
|
15
|
+
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test:e2e": "
|
|
7
|
-
"test:e2e:ui": "
|
|
8
|
-
"test:e2e:api": "
|
|
6
|
+
"test:e2e": "bun run-e2e.ts",
|
|
7
|
+
"test:e2e:ui": "bun run-e2e.ts --project=ui --project=ui-exclusive",
|
|
8
|
+
"test:e2e:api": "bun run-e2e.ts --project=api --project=api-exclusive",
|
|
9
9
|
"test:e2e:report": "bunx playwright show-report"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
|
|
3
3
|
|
|
4
4
|
// The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
|
|
5
5
|
// the API). Start it first (see docs/testing.md) — locally via the dev harness,
|
|
6
6
|
// in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
|
|
7
7
|
// and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
|
|
8
8
|
// E2E_WORKERS sets the worker count (default 1); tests tagged @exclusive run in
|
|
9
|
-
// `<project>-exclusive` twins after the others
|
|
9
|
+
// `<project>-exclusive` twins after the others, in a second run when run-e2e.ts
|
|
10
|
+
// starts the suite on more workers (see playwright.extensions.ts).
|
|
10
11
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
11
|
-
const
|
|
12
|
+
const run = e2eRunSettings();
|
|
12
13
|
const coreProjects = [
|
|
13
14
|
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
14
15
|
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
@@ -19,13 +20,14 @@ export default defineConfig({
|
|
|
19
20
|
fullyParallel: true,
|
|
20
21
|
forbidOnly: !!process.env.CI,
|
|
21
22
|
retries: process.env.CI ? 2 : 0,
|
|
22
|
-
workers,
|
|
23
|
+
workers: run.workers,
|
|
24
|
+
outputDir: run.outputDir,
|
|
23
25
|
globalSetup: "./playwright.extensions.ts",
|
|
24
|
-
reporter: [["list"], ["html", { open: "never" }]],
|
|
26
|
+
reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
|
|
25
27
|
use: {
|
|
26
28
|
baseURL,
|
|
27
29
|
trace: "on-first-retry",
|
|
28
30
|
screenshot: "only-on-failure",
|
|
29
31
|
},
|
|
30
|
-
projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
|
|
32
|
+
projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
|
|
31
33
|
});
|
|
@@ -17,6 +17,13 @@ const requireFromTests = createRequire(__filename);
|
|
|
17
17
|
// the test's tags, separated by spaces.
|
|
18
18
|
const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
|
|
19
19
|
const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
|
|
20
|
+
const EVERY_FILE = /.*/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One of the two Playwright runs `run-e2e.ts` makes with more than one worker:
|
|
24
|
+
* `shared` runs the tests not tagged `@exclusive`, `exclusive` the tagged ones.
|
|
25
|
+
*/
|
|
26
|
+
export type E2ePhase = "shared" | "exclusive";
|
|
20
27
|
|
|
21
28
|
function projectName(project: unknown): string | null {
|
|
22
29
|
if (typeof project !== "object" || project === null || !("name" in project)) return null;
|
|
@@ -69,29 +76,74 @@ export function e2eWorkers(value = process.env.E2E_WORKERS): number {
|
|
|
69
76
|
return workers;
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** The run `run-e2e.ts` started, from `E2E_PHASE`, or undefined for a single run. */
|
|
80
|
+
export function e2ePhase(value = process.env.E2E_PHASE): E2ePhase | undefined {
|
|
81
|
+
if (value === undefined || value === "") return undefined;
|
|
82
|
+
if (value === "shared" || value === "exclusive") return value;
|
|
83
|
+
throw new Error(`E2E_PHASE must be "shared" or "exclusive", got "${value}".`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Workers and output folders for this Playwright run. The exclusive phase runs
|
|
88
|
+
* on one worker and keeps its results and HTML report in `exclusive/` folders,
|
|
89
|
+
* because each run empties its own.
|
|
90
|
+
*/
|
|
91
|
+
export function e2eRunSettings(
|
|
92
|
+
phase = e2ePhase(),
|
|
93
|
+
workers = e2eWorkers(),
|
|
94
|
+
): { phase: E2ePhase | undefined; workers: number; outputDir: string; reportFolder: string } {
|
|
95
|
+
return phase === "exclusive"
|
|
96
|
+
? { phase, workers: 1, outputDir: "test-results/exclusive", reportFolder: "playwright-report/exclusive" }
|
|
97
|
+
: { phase, workers, outputDir: "test-results", reportFolder: "playwright-report" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Playwright runs for `run-e2e.ts` with these command-line arguments. One
|
|
102
|
+
* worker needs a single run. With more workers, the tests not tagged
|
|
103
|
+
* `@exclusive` run first and the tagged ones second, in a run of their own that
|
|
104
|
+
* starts even when the first one failed. Either run may find no tests: a filter
|
|
105
|
+
* can select tests of one kind only, and an app may have no exclusive tests.
|
|
106
|
+
*/
|
|
107
|
+
export function e2eRuns(args: string[], workers = e2eWorkers()): Array<{ phase?: E2ePhase; args: string[] }> {
|
|
108
|
+
if (workers === 1) return [{ args }];
|
|
109
|
+
return [
|
|
110
|
+
{ phase: "shared", args: args.length > 0 ? ["--pass-with-no-tests", ...args] : args },
|
|
111
|
+
{ phase: "exclusive", args: ["--pass-with-no-tests", ...args] },
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
/**
|
|
73
116
|
* Splits each test project into one that skips tests tagged `@exclusive` and an
|
|
74
117
|
* `<name>-exclusive` twin that runs only them on one worker. Projects that
|
|
75
118
|
* another project depends on, or that tear one down, are setup and cleanup
|
|
76
119
|
* projects and stay as they are.
|
|
77
120
|
*
|
|
78
|
-
* With
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* other test
|
|
83
|
-
*
|
|
84
|
-
*
|
|
121
|
+
* With one worker no ordering is needed. With more, `run-e2e.ts` runs
|
|
122
|
+
* Playwright twice: in the `shared` phase the twins find no tests, and in the
|
|
123
|
+
* `exclusive` phase, on one worker, the other test projects find none. A single
|
|
124
|
+
* run with more than one worker (no phase) makes the twins depend on every
|
|
125
|
+
* other test project, and each twin on the one before it, so an exclusive test
|
|
126
|
+
* still never runs alongside another test; but there a failing test skips every
|
|
127
|
+
* twin after it, and a filter that selects exclusive tests runs all other tests
|
|
128
|
+
* first.
|
|
85
129
|
*/
|
|
86
|
-
export function withExclusivePhase(projects: Projects, workers: number): Projects {
|
|
130
|
+
export function withExclusivePhase(projects: Projects, workers: number, phase?: E2ePhase): Projects {
|
|
87
131
|
const support = new Set(
|
|
88
132
|
projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
|
|
89
133
|
);
|
|
90
134
|
const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
|
|
91
135
|
const shared = new Map(
|
|
92
|
-
tested.map((project) => [
|
|
136
|
+
tested.map((project) => [
|
|
137
|
+
project,
|
|
138
|
+
{
|
|
139
|
+
...project,
|
|
140
|
+
grepInvert: [...patterns(project.grepInvert), TAGGED],
|
|
141
|
+
...(phase === "exclusive" ? { testIgnore: EVERY_FILE } : {}),
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
93
144
|
);
|
|
94
145
|
|
|
146
|
+
const chained = workers > 1 && phase === undefined;
|
|
95
147
|
let before = [...shared.values()].map((project) => projectName(project) ?? "");
|
|
96
148
|
const twins = tested.map((project) => {
|
|
97
149
|
const name = `${projectName(project) ?? ""}-exclusive`;
|
|
@@ -100,7 +152,8 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
|
|
|
100
152
|
name,
|
|
101
153
|
grepInvert: [...patterns(project.grepInvert), UNTAGGED],
|
|
102
154
|
workers: 1,
|
|
103
|
-
dependencies:
|
|
155
|
+
dependencies: chained ? [...(project.dependencies ?? []), ...before] : project.dependencies,
|
|
156
|
+
...(phase === "shared" ? { testIgnore: EVERY_FILE } : {}),
|
|
104
157
|
};
|
|
105
158
|
before = [name];
|
|
106
159
|
return twin;
|
|
@@ -119,13 +172,21 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
|
|
|
119
172
|
/**
|
|
120
173
|
* Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
|
|
121
174
|
* changes the count after the projects were built, so with it the exclusive
|
|
122
|
-
* twins would run alongside the other tests.
|
|
175
|
+
* twins would run alongside the other tests. Warns when more than one worker
|
|
176
|
+
* runs without `run-e2e.ts`, where a failing test skips the exclusive tests.
|
|
123
177
|
*/
|
|
124
|
-
export default function checkWorkers(config: FullConfig): void {
|
|
178
|
+
export default function checkWorkers(config: FullConfig, warn = console.warn): void {
|
|
125
179
|
if (config.workers > 1 && e2eWorkers() === 1) {
|
|
126
180
|
throw new Error(
|
|
127
181
|
`Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
|
|
128
182
|
"Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
|
|
129
183
|
);
|
|
130
184
|
}
|
|
185
|
+
if (config.workers > 1 && e2ePhase() === undefined) {
|
|
186
|
+
warn(
|
|
187
|
+
"E2E_WORKERS is above 1 in a single Playwright run: a failing test makes Playwright skip " +
|
|
188
|
+
"the tests tagged @exclusive. Run the suite with `bun run test:e2e` (run-e2e.ts), which " +
|
|
189
|
+
"runs them in a second run instead.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
131
192
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { e2eRuns } from "./playwright.extensions";
|
|
3
|
+
|
|
4
|
+
// Runs the suite with the arguments it is given, such as `--grep` or a file.
|
|
5
|
+
// With E2E_WORKERS above 1, Playwright runs twice: the tests not tagged
|
|
6
|
+
// @exclusive on that many workers, then the tagged ones on one worker. The
|
|
7
|
+
// second run starts even when the first one failed, so a failing test cannot
|
|
8
|
+
// hide the exclusive tests, and the exit code is that of the first failed run.
|
|
9
|
+
const runs = e2eRuns(process.argv.slice(2));
|
|
10
|
+
let status = 0;
|
|
11
|
+
for (const run of runs) {
|
|
12
|
+
if (run.phase) {
|
|
13
|
+
console.log(
|
|
14
|
+
run.phase === "shared"
|
|
15
|
+
? "\nRunning the tests not tagged @exclusive\n"
|
|
16
|
+
: "\nRunning the tests tagged @exclusive on one worker (report: playwright-report/exclusive)\n",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const result = spawnSync("bunx", ["playwright", "test", ...run.args], {
|
|
20
|
+
stdio: "inherit",
|
|
21
|
+
env: run.phase ? { ...process.env, E2E_PHASE: run.phase } : process.env,
|
|
22
|
+
});
|
|
23
|
+
if (result.error) throw result.error;
|
|
24
|
+
// Playwright exits with 130 when it is interrupted: stop instead of starting the next run.
|
|
25
|
+
if (result.signal !== null || result.status === 130) process.exit(result.status ?? 130);
|
|
26
|
+
if (status === 0) status = result.status ?? 1;
|
|
27
|
+
}
|
|
28
|
+
process.exit(status);
|
|
@@ -13,8 +13,20 @@ vi.mock("./app-nav", async () => {
|
|
|
13
13
|
};
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
// Stands in for the entries installed modules add to registry.svelte.ts, so the
|
|
17
|
+
// expected order does not depend on which modules an application installs.
|
|
18
|
+
vi.mock("./registry.svelte.js", async () => {
|
|
19
|
+
const { default: icon } = await import("@lucide/svelte/icons/newspaper");
|
|
20
|
+
return {
|
|
21
|
+
moduleNavEntries: [
|
|
22
|
+
{ href: "/admin/blog", label: () => "Blog", icon, adminOnly: true },
|
|
23
|
+
{ href: "/admin/reports", label: () => "Reports", icon },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
16
28
|
describe("admin navigation", () => {
|
|
17
|
-
it("places the application's entries before Settings", () => {
|
|
29
|
+
it("places the application's entries, then module entries, before Settings", () => {
|
|
18
30
|
const hrefs = adminNavEntries({ auditLog: true }, "admin").map((entry) => entry.href);
|
|
19
31
|
|
|
20
32
|
expect(hrefs).toEqual([
|
|
@@ -24,6 +36,8 @@ describe("admin navigation", () => {
|
|
|
24
36
|
"/admin/audit",
|
|
25
37
|
"/admin/specs",
|
|
26
38
|
"/admin/help",
|
|
39
|
+
"/admin/blog",
|
|
40
|
+
"/admin/reports",
|
|
27
41
|
"/admin/settings",
|
|
28
42
|
]);
|
|
29
43
|
expect(adminNavEntries(undefined, "admin").find((entry) => entry.href === "/admin/specs")?.icon).toBe(
|
|
@@ -31,9 +45,9 @@ describe("admin navigation", () => {
|
|
|
31
45
|
);
|
|
32
46
|
});
|
|
33
47
|
|
|
34
|
-
it("hides admin-only application entries from other roles", () => {
|
|
48
|
+
it("hides admin-only application and module entries from other roles", () => {
|
|
35
49
|
const hrefs = adminNavEntries(undefined, "moderator").map((entry) => entry.href);
|
|
36
50
|
|
|
37
|
-
expect(hrefs).toEqual(["/admin", "/admin/help"]);
|
|
51
|
+
expect(hrefs).toEqual(["/admin", "/admin/help", "/admin/reports"]);
|
|
38
52
|
});
|
|
39
53
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<DropdownMenu.Root>
|
|
14
14
|
<DropdownMenu.Trigger>
|
|
15
15
|
{#snippet child({ props })}
|
|
16
|
-
<Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle}>
|
|
16
|
+
<Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle} data-testid="theme-toggle">
|
|
17
17
|
<SunIcon class="size-4 dark:hidden" />
|
|
18
18
|
<MoonIcon class="hidden size-4 dark:block" />
|
|
19
19
|
</Button>
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import type { Page } from "@playwright/test";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Waits until SvelteKit has attached the current document's event handlers. The
|
|
5
|
+
* root layout adds this marker from onMount, so no fixed timing assumption is
|
|
6
|
+
* needed even while the development server compiles a route for the first time.
|
|
7
|
+
* Call it after `page.reload()` too: the reloaded document is visible, and
|
|
8
|
+
* clickable, before its handlers exist, so an early click does nothing.
|
|
9
|
+
*/
|
|
10
|
+
export async function hydrated(page: Page): Promise<void> {
|
|
11
|
+
await page.locator('html[data-hydrated="true"]').waitFor();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Opens `path` and waits for hydration before a test drives the page. */
|
|
6
15
|
export async function ready(page: Page, path: string): Promise<void> {
|
|
7
16
|
await page.goto(path);
|
|
8
|
-
await
|
|
17
|
+
await hydrated(page);
|
|
9
18
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
|
|
3
3
|
|
|
4
4
|
// admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
|
|
5
5
|
// (storageState) which the `ui` project reuses, and a `cleanup` project that
|
|
@@ -7,9 +7,10 @@ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwr
|
|
|
7
7
|
// E2E_BASE_URL. Tests share one backend and database: E2E_WORKERS sets the
|
|
8
8
|
// worker count (default 1), and tests tagged @exclusive, which change state the
|
|
9
9
|
// others rely on, run one at a time in `<project>-exclusive` twins after the
|
|
10
|
-
// others
|
|
10
|
+
// others, in a second run when run-e2e.ts starts the suite on more workers (see
|
|
11
|
+
// playwright.extensions.ts). Each file runs on one worker in order.
|
|
11
12
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
12
|
-
const
|
|
13
|
+
const run = e2eRunSettings();
|
|
13
14
|
const coreProjects = [
|
|
14
15
|
{ name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
|
|
15
16
|
{ name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
|
|
@@ -27,9 +28,10 @@ export default defineConfig({
|
|
|
27
28
|
fullyParallel: false,
|
|
28
29
|
forbidOnly: !!process.env.CI,
|
|
29
30
|
retries: process.env.CI ? 2 : 0,
|
|
30
|
-
workers,
|
|
31
|
+
workers: run.workers,
|
|
32
|
+
outputDir: run.outputDir,
|
|
31
33
|
globalSetup: "./playwright.extensions.ts",
|
|
32
|
-
reporter: [["list"], ["html", { open: "never" }]],
|
|
34
|
+
reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
|
|
33
35
|
use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
|
|
34
|
-
projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
|
|
36
|
+
projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
|
|
35
37
|
});
|
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
import { expect, test } from "@playwright/test";
|
|
2
|
-
import { ready } from "../helpers/hydration";
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { hydrated, ready } from "../helpers/hydration";
|
|
3
|
+
import { paths } from "../helpers/paths";
|
|
4
|
+
|
|
5
|
+
async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promise<void> {
|
|
6
|
+
await page.getByTestId("theme-toggle").click();
|
|
7
|
+
await page.getByRole("menuitem", { name: mode }).click();
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
// mode-watcher marks <html> with `.dark` or `.light`, so component libraries
|
|
5
11
|
// that follow either class, and fall back to the OS setting otherwise, match the
|
|
6
|
-
// theme the visitor chose rather than the system one.
|
|
12
|
+
// theme the visitor chose rather than the system one. The managed ThemeToggle
|
|
13
|
+
// renders on the page app-paths.ts names as the account menu page.
|
|
7
14
|
test("the chosen theme is named on <html> in both modes", async ({ page }) => {
|
|
15
|
+
test.skip(paths.accountMenu === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
|
|
8
16
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
9
|
-
await ready(page, "/
|
|
17
|
+
await ready(page, paths.accountMenu ?? "/");
|
|
10
18
|
const html = page.locator("html");
|
|
11
19
|
|
|
12
|
-
await page
|
|
13
|
-
await page.getByRole("menuitem", { name: "Light" }).click();
|
|
20
|
+
await chooseTheme(page, "Light");
|
|
14
21
|
await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
|
|
15
22
|
await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
|
|
16
23
|
|
|
17
24
|
// The class is set before the page renders, not only after a choice.
|
|
18
25
|
await page.reload();
|
|
19
26
|
await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
|
|
27
|
+
await hydrated(page);
|
|
20
28
|
|
|
21
|
-
await page
|
|
22
|
-
await page.getByRole("menuitem", { name: "Dark" }).click();
|
|
29
|
+
await chooseTheme(page, "Dark");
|
|
23
30
|
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
|
|
24
31
|
await expect(html).not.toHaveClass(/(^|\s)light(\s|$)/);
|
|
25
32
|
|
|
26
|
-
await page
|
|
27
|
-
await page.getByRole("menuitem", { name: "System" }).click();
|
|
33
|
+
await chooseTheme(page, "System");
|
|
28
34
|
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
|
|
29
35
|
});
|
|
@@ -136,6 +136,9 @@ describe("BullMqJobQueue schedules", () => {
|
|
|
136
136
|
await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
|
|
137
137
|
await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
|
|
138
138
|
await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
|
|
139
|
+
await expect(queue.schedule("bad", "report", {}, { everyMs: 1_000 }, { keepFailed: 1.5 })).rejects.toThrow(
|
|
140
|
+
"keepFailed must be a whole number of at least 0",
|
|
141
|
+
);
|
|
139
142
|
} finally {
|
|
140
143
|
await queue.close();
|
|
141
144
|
}
|
|
@@ -183,4 +186,51 @@ describe.skipIf(!process.env.REDIS_URL && !process.env.REDIS_HOST)("BullMqJobQue
|
|
|
183
186
|
await inspect.close();
|
|
184
187
|
}
|
|
185
188
|
});
|
|
189
|
+
|
|
190
|
+
it("limits the finished jobs a schedule's runs leave, 100 completed and 1000 failed by default", async () => {
|
|
191
|
+
const name = `schedule-test-${crypto.randomUUID()}`;
|
|
192
|
+
const queue = new BullMqJobQueue(producerConnection(REDIS));
|
|
193
|
+
const inspect = new Queue(name, { connection: redisConnection(REDIS) });
|
|
194
|
+
try {
|
|
195
|
+
await queue.connect();
|
|
196
|
+
await queue.schedule("default", name, {}, { cron: "0 3 * * *" });
|
|
197
|
+
await queue.schedule("custom", name, {}, { everyMs: 3_600_000 }, { keepCompleted: 0, keepFailed: 20 });
|
|
198
|
+
expect((await inspect.getJobScheduler("default"))?.template?.opts).toMatchObject({
|
|
199
|
+
removeOnComplete: { count: 100 },
|
|
200
|
+
removeOnFail: { count: 1_000 },
|
|
201
|
+
});
|
|
202
|
+
expect((await inspect.getJobScheduler("custom"))?.template?.opts).toMatchObject({
|
|
203
|
+
removeOnComplete: { count: 0 },
|
|
204
|
+
removeOnFail: { count: 20 },
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
await queue.close();
|
|
208
|
+
await inspect.obliterate({ force: true });
|
|
209
|
+
await inspect.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("removes finished runs beyond the schedule's limits", async () => {
|
|
214
|
+
const name = `schedule-test-${crypto.randomUUID()}`;
|
|
215
|
+
const queue = new BullMqJobQueue(producerConnection(REDIS));
|
|
216
|
+
const inspect = new Queue(name, { connection: redisConnection(REDIS) });
|
|
217
|
+
let runs = 0;
|
|
218
|
+
try {
|
|
219
|
+
await queue.connect();
|
|
220
|
+
await queue.process(name, () => {
|
|
221
|
+
runs += 1;
|
|
222
|
+
if (runs % 2 === 0) throw new Error("expected failure");
|
|
223
|
+
});
|
|
224
|
+
await queue.schedule("tick", name, {}, { everyMs: 1_000 }, { keepCompleted: 1, keepFailed: 1 });
|
|
225
|
+
for (let waited = 0; runs < 4 && waited < 10_000; waited += 100) await Bun.sleep(100);
|
|
226
|
+
expect(runs).toBeGreaterThanOrEqual(4);
|
|
227
|
+
// The last run's job moves to its finished set just after the handler returns.
|
|
228
|
+
await Bun.sleep(200);
|
|
229
|
+
expect(await inspect.getJobCounts("completed", "failed")).toEqual({ completed: 1, failed: 1 });
|
|
230
|
+
} finally {
|
|
231
|
+
await queue.close();
|
|
232
|
+
await inspect.obliterate({ force: true });
|
|
233
|
+
await inspect.close();
|
|
234
|
+
}
|
|
235
|
+
}, 15_000);
|
|
186
236
|
});
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
type JobSnapshot,
|
|
11
11
|
type JobStatus,
|
|
12
12
|
type RepeatOptions,
|
|
13
|
+
type ScheduleOptions,
|
|
14
|
+
type ScheduleRetention,
|
|
15
|
+
scheduleRetention,
|
|
13
16
|
type Unsubscribe,
|
|
14
17
|
} from "@podosoft/podokit-runtime";
|
|
15
18
|
import type { SupervisedConnection } from "../core/services";
|
|
@@ -56,6 +59,7 @@ interface Schedule {
|
|
|
56
59
|
name: string;
|
|
57
60
|
payload: unknown;
|
|
58
61
|
repeat: RepeatOptions;
|
|
62
|
+
retention: ScheduleRetention;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/** Rejects a schedule BullMQ could not run, before it is kept. */
|
|
@@ -141,13 +145,20 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
|
141
145
|
};
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
async schedule(
|
|
148
|
+
async schedule(
|
|
149
|
+
id: string,
|
|
150
|
+
name: string,
|
|
151
|
+
payload: unknown,
|
|
152
|
+
repeat: RepeatOptions,
|
|
153
|
+
options?: ScheduleOptions,
|
|
154
|
+
): Promise<void> {
|
|
145
155
|
assertSchedule(repeat);
|
|
156
|
+
const retention = scheduleRetention(options);
|
|
146
157
|
const previous = this.schedules.get(id);
|
|
147
158
|
// A scheduler lives in its queue, so a schedule that moves leaves the old one.
|
|
148
159
|
if (previous && previous.name !== name) this.removals.set(id, previous.name);
|
|
149
160
|
if (this.removals.get(id) === name) this.removals.delete(id);
|
|
150
|
-
this.schedules.set(id, { name, payload, repeat });
|
|
161
|
+
this.schedules.set(id, { name, payload, repeat, retention });
|
|
151
162
|
await this.whenConnected(async (client) => {
|
|
152
163
|
if (this.removals.has(id)) await this.removeSchedule(client, id);
|
|
153
164
|
await this.applySchedule(client, id);
|
|
@@ -196,10 +207,19 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
|
196
207
|
const schedule = this.schedules.get(id);
|
|
197
208
|
if (!schedule) return;
|
|
198
209
|
const { cron, everyMs, timezone } = schedule.repeat;
|
|
210
|
+
// A count above 0 trims the queue's whole completed or failed set to that
|
|
211
|
+
// many jobs; 0 removes only the finished run.
|
|
199
212
|
await this.queue(schedule.name, client).upsertJobScheduler(
|
|
200
213
|
id,
|
|
201
214
|
cron !== undefined ? { pattern: cron, tz: timezone ?? "UTC" } : { every: everyMs },
|
|
202
|
-
{
|
|
215
|
+
{
|
|
216
|
+
name: schedule.name,
|
|
217
|
+
data: schedule.payload,
|
|
218
|
+
opts: {
|
|
219
|
+
removeOnComplete: { count: schedule.retention.keepCompleted },
|
|
220
|
+
removeOnFail: { count: schedule.retention.keepFailed },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
203
223
|
);
|
|
204
224
|
}
|
|
205
225
|
|
package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts
CHANGED
|
@@ -149,6 +149,93 @@ describe("local job schedules", () => {
|
|
|
149
149
|
await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
|
|
150
150
|
await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
|
|
151
151
|
await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
|
|
152
|
+
await expect(queue.schedule("bad", "report", {}, { everyMs: 1_000 }, { keepCompleted: -1 })).rejects.toThrow(
|
|
153
|
+
"keepCompleted must be a whole number of at least 0",
|
|
154
|
+
);
|
|
155
|
+
queue.close();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Moves the clock to each time and runs the job the schedule added then. */
|
|
160
|
+
async function runScheduled(queue: LocalJobQueue, time: ReturnType<typeof manualTime>, times: number[]): Promise<void> {
|
|
161
|
+
for (const at of times) {
|
|
162
|
+
time.advance(at);
|
|
163
|
+
// The schedule adds its job without waiting; let the insert finish.
|
|
164
|
+
await Bun.sleep(1);
|
|
165
|
+
expect(await queue.runOnce()).toBe(true);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function finishedJobs(database: SQL): Promise<string[]> {
|
|
170
|
+
const rows = await database<Array<{ id: string; status: string }>>`
|
|
171
|
+
SELECT "id", "status" FROM "podokit_job" ORDER BY "updatedAt", "id"
|
|
172
|
+
`;
|
|
173
|
+
return rows.map((row) => `${row.id} ${row.status}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("local job schedule cleanup", () => {
|
|
177
|
+
it("keeps the newest finished jobs of a scheduled run's name up to the schedule's limits", async () => {
|
|
178
|
+
sql = new SQL(":memory:");
|
|
179
|
+
const time = manualTime(0);
|
|
180
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
181
|
+
await queue.process("report", (_payload, job) => {
|
|
182
|
+
if (job.id.endsWith(":3000") || job.id.endsWith(":5000")) throw new Error("expected failure");
|
|
183
|
+
});
|
|
184
|
+
await queue.process("other", () => undefined);
|
|
185
|
+
const other = await queue.enqueue("other", {});
|
|
186
|
+
const adHoc = await queue.enqueue("report", {});
|
|
187
|
+
while (await queue.runOnce());
|
|
188
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 2, keepFailed: 1 });
|
|
189
|
+
await queue.start();
|
|
190
|
+
await Bun.sleep(5);
|
|
191
|
+
|
|
192
|
+
await runScheduled(queue, time, [1_000, 2_000, 3_000, 4_000, 5_000, 6_000]);
|
|
193
|
+
|
|
194
|
+
// As BullMQ trims a queue: the job enqueue() added goes too, another name's stays.
|
|
195
|
+
expect(await finishedJobs(sql)).toEqual([
|
|
196
|
+
`${other.id} completed`,
|
|
197
|
+
"report:schedule:tick:4000 completed",
|
|
198
|
+
"report:schedule:tick:5000 failed",
|
|
199
|
+
"report:schedule:tick:6000 completed",
|
|
200
|
+
]);
|
|
201
|
+
expect(await queue.get(adHoc.id)).toBeNull();
|
|
202
|
+
queue.close();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("removes only the run itself when a limit is 0", async () => {
|
|
206
|
+
sql = new SQL(":memory:");
|
|
207
|
+
const time = manualTime(0);
|
|
208
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
209
|
+
await queue.process("report", (_payload, job) => {
|
|
210
|
+
if (job.id.endsWith(":2000")) throw new Error("expected failure");
|
|
211
|
+
});
|
|
212
|
+
const adHoc = await queue.enqueue("report", {});
|
|
213
|
+
while (await queue.runOnce());
|
|
214
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 0, keepFailed: 0 });
|
|
215
|
+
await queue.start();
|
|
216
|
+
await Bun.sleep(5);
|
|
217
|
+
|
|
218
|
+
await runScheduled(queue, time, [1_000, 2_000, 3_000]);
|
|
219
|
+
|
|
220
|
+
expect(await finishedJobs(sql)).toEqual([`${adHoc.id} completed`]);
|
|
221
|
+
queue.close();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("keeps 100 completed runs of a schedule by default", async () => {
|
|
225
|
+
sql = new SQL(":memory:");
|
|
226
|
+
const time = manualTime(0);
|
|
227
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
228
|
+
await queue.process("report", () => undefined);
|
|
229
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 });
|
|
230
|
+
await queue.start();
|
|
231
|
+
await Bun.sleep(5);
|
|
232
|
+
|
|
233
|
+
await runScheduled(queue, time, Array.from({ length: 105 }, (_, index) => (index + 1) * 1_000));
|
|
234
|
+
|
|
235
|
+
const kept = await finishedJobs(sql);
|
|
236
|
+
expect(kept).toHaveLength(100);
|
|
237
|
+
expect(kept[0]).toBe("report:schedule:tick:6000 completed");
|
|
238
|
+
expect(kept.at(-1)).toBe("report:schedule:tick:105000 completed");
|
|
152
239
|
queue.close();
|
|
153
240
|
});
|
|
154
241
|
});
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
type JobSnapshot,
|
|
10
10
|
type JobStatus,
|
|
11
11
|
type RepeatOptions,
|
|
12
|
+
type ScheduleOptions,
|
|
13
|
+
type ScheduleRetention,
|
|
14
|
+
scheduleRetention,
|
|
12
15
|
type Unsubscribe,
|
|
13
16
|
} from "@podosoft/podokit-runtime";
|
|
14
17
|
|
|
@@ -42,9 +45,15 @@ interface Schedule {
|
|
|
42
45
|
name: string;
|
|
43
46
|
payload: unknown;
|
|
44
47
|
repeat: RepeatOptions;
|
|
48
|
+
retention: ScheduleRetention;
|
|
45
49
|
cancel?: () => void;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
type FinishedStatus = "completed" | "failed";
|
|
53
|
+
|
|
54
|
+
// A scheduled job's deduplication key: this prefix, the schedule id, ":", and the time.
|
|
55
|
+
const SCHEDULE_KEY = "schedule:";
|
|
56
|
+
|
|
48
57
|
// setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
|
|
49
58
|
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
50
59
|
|
|
@@ -160,13 +169,20 @@ export class LocalJobQueue implements JobQueue {
|
|
|
160
169
|
});
|
|
161
170
|
}
|
|
162
171
|
|
|
163
|
-
async schedule(
|
|
172
|
+
async schedule(
|
|
173
|
+
id: string,
|
|
174
|
+
name: string,
|
|
175
|
+
payload: unknown,
|
|
176
|
+
repeat: RepeatOptions,
|
|
177
|
+
options?: ScheduleOptions,
|
|
178
|
+
): Promise<void> {
|
|
164
179
|
assertRepeatOptions(repeat);
|
|
180
|
+
const retention = scheduleRetention(options);
|
|
165
181
|
// Rejects an invalid cron expression or time zone now rather than at the first run.
|
|
166
182
|
nextScheduledTime(repeat, this.now());
|
|
167
183
|
if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
|
|
168
184
|
this.schedules.get(id)?.cancel?.();
|
|
169
|
-
const schedule: Schedule = { id, name, payload, repeat };
|
|
185
|
+
const schedule: Schedule = { id, name, payload, repeat, retention };
|
|
170
186
|
this.schedules.set(id, schedule);
|
|
171
187
|
if (this.started) this.arm(schedule);
|
|
172
188
|
}
|
|
@@ -205,6 +221,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
205
221
|
SET "status" = 'completed', "updatedAt" = ${completedAt}, "error" = NULL
|
|
206
222
|
WHERE "id" = ${row.id}
|
|
207
223
|
`;
|
|
224
|
+
await this.removeFinished(row, "completed");
|
|
208
225
|
} catch (error) {
|
|
209
226
|
await this.fail(row, error);
|
|
210
227
|
}
|
|
@@ -227,7 +244,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
227
244
|
const at = nextScheduledTime(schedule.repeat, after);
|
|
228
245
|
schedule.cancel = this.waitUntil(at, () => {
|
|
229
246
|
if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
|
|
230
|
-
void this.enqueue(schedule.name, schedule.payload, { deduplicationKey:
|
|
247
|
+
void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `${SCHEDULE_KEY}${schedule.id}:${at}` })
|
|
231
248
|
.catch((error: unknown) => {
|
|
232
249
|
const message = error instanceof Error ? error.message : String(error);
|
|
233
250
|
process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
|
|
@@ -269,6 +286,45 @@ export class LocalJobQueue implements JobQueue {
|
|
|
269
286
|
CREATE INDEX IF NOT EXISTS "podokit_job_pending"
|
|
270
287
|
ON "podokit_job" ("status", "availableAt", "createdAt")
|
|
271
288
|
`;
|
|
289
|
+
await this.sql`
|
|
290
|
+
CREATE INDEX IF NOT EXISTS "podokit_job_finished"
|
|
291
|
+
ON "podokit_job" ("name", "status", "updatedAt")
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The schedule whose job id `row` has, while that schedule is kept. */
|
|
296
|
+
private scheduleOf(row: JobRow): Schedule | undefined {
|
|
297
|
+
for (const schedule of this.schedules.values()) {
|
|
298
|
+
const prefix = `${schedule.name}:${SCHEDULE_KEY}${schedule.id}:`;
|
|
299
|
+
if (schedule.name === row.name && row.id.startsWith(prefix) && /^\d+$/.test(row.id.slice(prefix.length))) {
|
|
300
|
+
return schedule;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* After a scheduled run finishes, keeps the newest finished jobs of its name
|
|
308
|
+
* up to the schedule's limit, as BullMQ trims a queue, or removes only the
|
|
309
|
+
* run when the limit is 0.
|
|
310
|
+
*/
|
|
311
|
+
private async removeFinished(row: JobRow, status: FinishedStatus): Promise<void> {
|
|
312
|
+
const schedule = this.scheduleOf(row);
|
|
313
|
+
if (!schedule) return;
|
|
314
|
+
const keep = status === "completed" ? schedule.retention.keepCompleted : schedule.retention.keepFailed;
|
|
315
|
+
if (keep === 0) {
|
|
316
|
+
await this.sql`DELETE FROM "podokit_job" WHERE "id" = ${row.id}`;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await this.sql`
|
|
320
|
+
DELETE FROM "podokit_job"
|
|
321
|
+
WHERE "name" = ${row.name} AND "status" = ${status} AND "id" NOT IN (
|
|
322
|
+
SELECT "id" FROM "podokit_job"
|
|
323
|
+
WHERE "name" = ${row.name} AND "status" = ${status}
|
|
324
|
+
ORDER BY "updatedAt" DESC, "id" DESC
|
|
325
|
+
LIMIT ${keep}
|
|
326
|
+
)
|
|
327
|
+
`;
|
|
272
328
|
}
|
|
273
329
|
|
|
274
330
|
private poll(delay: number): void {
|
|
@@ -296,6 +352,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
296
352
|
"error" = ${message.slice(0, 2_000)}
|
|
297
353
|
WHERE "id" = ${row.id}
|
|
298
354
|
`;
|
|
355
|
+
if (!retry) await this.removeFinished(row, "failed");
|
|
299
356
|
}
|
|
300
357
|
|
|
301
358
|
private snapshot(row: JobRow): JobSnapshot {
|
package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { seededAdmin } from "../helpers/seeded-admin";
|
|
2
3
|
|
|
3
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
5
|
const origin = { origin: base };
|
|
5
|
-
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
-
// spec also compiles in apps that do not install that module.
|
|
7
|
-
const admin = {
|
|
8
|
-
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
|
-
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
-
};
|
|
11
6
|
|
|
12
7
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
8
|
|
|
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
|
36
31
|
|
|
37
32
|
/** Resolves false when no administrator session is available. */
|
|
38
33
|
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
34
|
+
const admin = await seededAdmin();
|
|
35
|
+
if (!admin) return false;
|
|
39
36
|
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
37
|
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
41
38
|
}
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { seededAdmin } from "../helpers/seeded-admin";
|
|
2
3
|
|
|
3
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
5
|
const origin = { origin: base };
|
|
5
|
-
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
-
// spec also compiles in apps that do not install that module.
|
|
7
|
-
const admin = {
|
|
8
|
-
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
|
-
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
-
};
|
|
11
6
|
|
|
12
7
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
8
|
|
|
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
|
36
31
|
|
|
37
32
|
/** Resolves false when no administrator session is available. */
|
|
38
33
|
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
34
|
+
const admin = await seededAdmin();
|
|
35
|
+
if (!admin) return false;
|
|
39
36
|
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
37
|
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
41
38
|
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { seededAdmin } from "../helpers/seeded-admin";
|
|
2
3
|
|
|
3
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
5
|
const secondaryApi = process.env.E2E_SECONDARY_API_URL;
|
|
5
6
|
const origin = { origin: base };
|
|
6
|
-
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
7
|
-
// spec also compiles in apps that do not install that module.
|
|
8
|
-
const admin = {
|
|
9
|
-
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
10
|
-
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
11
|
-
};
|
|
12
7
|
|
|
13
8
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
14
9
|
|
|
@@ -39,6 +34,8 @@ async function signUp(ctx: APIRequestContext): Promise<SessionUser | undefined>
|
|
|
39
34
|
|
|
40
35
|
/** Resolves false when no administrator session is available. */
|
|
41
36
|
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
37
|
+
const admin = await seededAdmin();
|
|
38
|
+
if (!admin) return false;
|
|
42
39
|
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
43
40
|
return response.ok() && ((await sessionUser(ctx))?.roles ?? []).includes("admin");
|
|
44
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test": "vitest run"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@podosoft/podokit-runtime": "^0.5.
|
|
48
|
+
"@podosoft/podokit-runtime": "^0.5.1",
|
|
49
49
|
"@podosoft/podokit-template-engine": "^0.4.2"
|
|
50
50
|
}
|
|
51
51
|
}
|