@krizaka/config 0.9.0 → 0.10.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.
@@ -1,116 +0,0 @@
1
- // The account baseline every Krizaka app owes (Oussama, 2026-10-22: « Avatar, etc. Ça doit être systématique »): the same
2
- // sections, from the `app-settings` template and the account-settings blocks of @krizaka/ui, on web and native. An app runs
3
- // `checkAccountBaseline` in its CI, signed in as a persona; it fails when a section is missing or renders empty.
4
-
5
- /** The sections of the account area. */
6
- export const AccountSection = Object.freeze({
7
- Profile: "profile",
8
- Preferences: "preferences",
9
- Security: "security",
10
- Billing: "billing",
11
- Notifications: "notifications",
12
- Data: "data",
13
- });
14
-
15
- /**
16
- * The controls a visit observes on a page, by what they let a person do. `password` and `file` are counted apart: a
17
- * password field has no `textbox` role, and an upload is a file input, not a radiogroup.
18
- * @typedef {"radiogroup" | "textbox" | "password" | "file" | "switch" | "checkbox" | "button" | "link"} Control
19
- */
20
-
21
- /**
22
- * What each section owes, and when. `needs` is the product feature that makes it required (a product that does not sell has
23
- * no billing section; one that sends nothing has no notifications). `owes` lists what must be there, each item a set of
24
- * alternatives (`[{ radiogroup: 1 }, { file: 1 }]`: an avatar picker or an upload): a heading alone never passes.
25
- * @type {readonly { id: string, needs: "billing" | "notifications" | null, owes: readonly { what: string, anyOf: readonly Partial<Record<Control, number>>[] }[], why: string }[]}
26
- */
27
- export const ACCOUNT_BASELINE = Object.freeze([
28
- {
29
- id: AccountSection.Profile,
30
- needs: null,
31
- owes: [
32
- { what: "an avatar (a picker or an upload)", anyOf: [{ radiogroup: 1 }, { file: 1 }] },
33
- { what: "a display name field", anyOf: [{ textbox: 1 }] },
34
- ],
35
- why: "avatar (a picker, or an upload), display name, e-mail with its verified status",
36
- },
37
- { id: AccountSection.Preferences, needs: null, owes: [{ what: "a language or theme choice", anyOf: [{ radiogroup: 1 }] }], why: "language and theme" },
38
- {
39
- id: AccountSection.Security,
40
- needs: null,
41
- owes: [{ what: "a password change or the active sessions", anyOf: [{ password: 1 }, { button: 1 }] }],
42
- why: "password change, active sessions",
43
- },
44
- { id: AccountSection.Billing, needs: "billing", owes: [{ what: "the way to the billing portal", anyOf: [{ link: 1 }, { button: 1 }] }], why: "the subscription and the billing portal, where the product sells" },
45
- {
46
- id: AccountSection.Notifications,
47
- needs: "notifications",
48
- owes: [{ what: "a setting per kind of message", anyOf: [{ switch: 1 }, { checkbox: 1 }] }],
49
- why: "what the product sends, where it sends anything",
50
- },
51
- {
52
- id: AccountSection.Data,
53
- needs: null,
54
- owes: [{ what: "an export and an account deletion", anyOf: [{ button: 2 }, { button: 1, link: 1 }, { link: 2 }] }],
55
- why: "my data: export, and account deletion with confirmation (Loi 25)",
56
- },
57
- ]);
58
-
59
- /**
60
- * @typedef {object} Visited what a visit of one section found
61
- * @property {number} status the HTTP status after redirects
62
- * @property {readonly string[]} headings the text of the page's headings
63
- * @property {Partial<Record<Control, number>>} controls how many of each control the page shows (a missing one is 0)
64
- */
65
-
66
- /** @param {Partial<Record<string, number>>} controls @param {Partial<Record<string, number>>} wanted */
67
- const meets = (controls, wanted) => Object.entries(wanted).every(([control, count]) => (controls[control] ?? 0) >= (count ?? 0));
68
-
69
- /** @param {Partial<Record<string, number>>} wanted */
70
- const spell = (wanted) => Object.entries(wanted).map(([control, count]) => `${count} ${control}`).join(" + ");
71
-
72
- /**
73
- * Checks the account baseline of an app.
74
- * @param {{ routes: Partial<Record<string, string>>, features?: { billing?: boolean, notifications?: boolean }, visit: (path: string) => Promise<Visited> }} options
75
- * `routes`: the route of each section; `visit`: signs in as a persona and opens a route (a Playwright page, or a fetch of the rendered HTML).
76
- * @returns {Promise<string[]>} one line per problem; empty when the app conforms
77
- */
78
- export async function checkAccountBaseline({ routes, features = {}, visit }) {
79
- /** @type {string[]} */
80
- const problems = [];
81
- for (const section of ACCOUNT_BASELINE) {
82
- if (section.needs && !features[section.needs]) continue;
83
- const path = routes[section.id];
84
- if (!path) {
85
- problems.push(`${section.id}: no route (${section.why})`);
86
- continue;
87
- }
88
- const page = await visit(path);
89
- if (page.status !== 200) problems.push(`${section.id} ${path}: answers ${page.status}`);
90
- else if (page.headings.length === 0) problems.push(`${section.id} ${path}: renders no heading`);
91
- else
92
- for (const item of section.owes)
93
- if (!item.anyOf.some((wanted) => meets(page.controls, wanted)))
94
- problems.push(`${section.id} ${path}: needs ${item.what} (${item.anyOf.map(spell).join(" or ")})`);
95
- }
96
- return problems;
97
- }
98
-
99
- /**
100
- * The `visit` of a Playwright page: opens `base + path` and reads the headings and the controls.
101
- * @param {any} page a Playwright page, already signed in as the persona
102
- * @param {string} base
103
- * @returns {(path: string) => Promise<Visited>}
104
- */
105
- export function playwrightVisit(page, base) {
106
- return async (path) => {
107
- const response = await page.goto(`${base.replace(/\/$/, "")}${path}`, { waitUntil: "networkidle" });
108
- /** @type {Partial<Record<Control, number>>} */
109
- const controls = {};
110
- for (const role of /** @type {const} */ (["radiogroup", "textbox", "switch", "checkbox", "button", "link"])) controls[role] = await page.getByRole(role).count();
111
- // No implicit role: a password field is not a textbox, a file input is a (hidden) input behind its button.
112
- controls.password = await page.locator('input[type="password"]').count();
113
- controls.file = await page.locator('input[type="file"]').count();
114
- return { status: response?.status() ?? 0, headings: await page.getByRole("heading").allInnerTexts(), controls };
115
- };
116
- }