@micha.bigler/ui-core-micha 3.9.1 → 3.11.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/CHANGELOG.md +10 -0
- package/DESIGN.md +11 -0
- package/README.md +16 -0
- package/WORK_ORDERS.md +6 -2
- package/dev/main.jsx +3 -3
- package/dist/index.js +2 -0
- package/dist/testing/checkKitIntegration.js +201 -0
- package/dist/theme/createAppTheme.js +123 -20
- package/dist/theme/themeCompleteness.js +13 -7
- package/dist/theme/tokens.js +42 -0
- package/docs/DARK-TOKENS.md +140 -0
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/testing/checkKitIntegration.js +233 -0
- package/src/theme/createAppTheme.js +139 -12
- package/src/theme/themeCompleteness.js +10 -4
- package/src/theme/tokens.js +72 -0
- package/tests/checkKitIntegration.test.jsx +219 -0
- package/tests/createAppThemeDark.test.js +127 -0
- package/tests/createAppThemeLightGuard.test.js +40 -0
- package/tests/fixtures/light-theme-baseline.json +2228 -0
- package/work-orders/UCM-I18N-5.md +80 -0
- package/work-orders/UCM-TEST-1.md +304 -0
- package/work-orders/UCM-THEME-14.md +178 -8
- package/work-orders/assets/UCM-THEME-13-dark-prototype.html +390 -0
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import './fonts';
|
|
2
2
|
|
|
3
|
-
import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
|
|
3
|
+
import { createTheme, darken, decomposeColor, getContrastRatio, lighten } from '@mui/material/styles';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS,
|
|
7
|
+
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK,
|
|
7
8
|
BASELINE_PALETTE,
|
|
9
|
+
BASELINE_PALETTE_DARK,
|
|
8
10
|
BASELINE_STATIC,
|
|
11
|
+
DARK_OVERLAY_SHADOW,
|
|
12
|
+
DARK_OVERLAY_SURFACE,
|
|
13
|
+
DARK_SCRIM,
|
|
9
14
|
MOTION,
|
|
10
15
|
withMainShades,
|
|
11
16
|
} from './tokens';
|
|
@@ -42,6 +47,21 @@ function clearsContrast(colour, surfaces) {
|
|
|
42
47
|
return surfaces.every((surface) => getContrastRatio(colour, surface) >= 3);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
function normalizeColour(colour) {
|
|
51
|
+
const match = colour.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
52
|
+
if (!match) return colour;
|
|
53
|
+
return `#${match.slice(1).map((channel) => Number(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lightenColour(colour, coefficient) {
|
|
57
|
+
const normalized = normalizeColour(colour);
|
|
58
|
+
const match = normalized.match(/^#([\da-f]{6})$/i);
|
|
59
|
+
if (!match) return normalizeColour(lighten(colour, coefficient));
|
|
60
|
+
const channels = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16));
|
|
61
|
+
return `#${channels.map((channel) => Math.round(channel + (255 - channel) * coefficient)
|
|
62
|
+
.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
// Darkened until it clears 3:1 against BOTH '#FFFFFF' and background.default
|
|
46
66
|
// -- the exact two surfaces assertThemeComplete's contrastFindings checks --
|
|
47
67
|
// so deriving against background.paper alone (which need not be white for an
|
|
@@ -55,6 +75,16 @@ function deriveFocusColour(primary, surfaces) {
|
|
|
55
75
|
return focus;
|
|
56
76
|
}
|
|
57
77
|
|
|
78
|
+
function deriveLightenedColour(original, surfaces) {
|
|
79
|
+
if (clearsContrast(original, surfaces)) return original;
|
|
80
|
+
let colour = original;
|
|
81
|
+
for (let coefficient = 0.05; !clearsContrast(colour, surfaces); coefficient += 0.05) {
|
|
82
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
83
|
+
if (coefficient >= 0.9) break;
|
|
84
|
+
}
|
|
85
|
+
return colour;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
function clearsPageContrast(colour, surfaces, threshold) {
|
|
59
89
|
return surfaces.every((surface) => {
|
|
60
90
|
const ratio = calculateContrastRatio(colour, surface);
|
|
@@ -73,6 +103,35 @@ function deriveDarkenedPageColour(original, surfaces, threshold) {
|
|
|
73
103
|
return colour;
|
|
74
104
|
}
|
|
75
105
|
|
|
106
|
+
function deriveLightenedPageColour(original, surfaces, threshold) {
|
|
107
|
+
if (clearsPageContrast(original, surfaces, threshold)) return original;
|
|
108
|
+
|
|
109
|
+
let colour = original;
|
|
110
|
+
for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
|
|
111
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
112
|
+
if (coefficient >= 0.9) break;
|
|
113
|
+
}
|
|
114
|
+
return colour;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ink.primary may be supplied as hex or rgb(); decomposeColor normalises both.
|
|
118
|
+
function inkChannels(ink) {
|
|
119
|
+
return decomposeColor(ink).values.slice(0, 3).map((value) => Math.round(value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 0.02 steps: the frozen sheet value (0.38) is the first even step that clears
|
|
123
|
+
// 3:1; a 0.01 search would land on 0.37 and drift from the frozen baseline.
|
|
124
|
+
function deriveDarkAlphaColour(ink, surfaces) {
|
|
125
|
+
const [red, green, blue] = inkChannels(ink);
|
|
126
|
+
let alpha = 0;
|
|
127
|
+
while (alpha <= 1) {
|
|
128
|
+
const colour = `rgba(${red},${green},${blue},${alpha.toFixed(2)})`;
|
|
129
|
+
if (clearsPageContrast(colour, surfaces, 3)) return colour;
|
|
130
|
+
alpha = Number((alpha + 0.02).toFixed(2));
|
|
131
|
+
}
|
|
132
|
+
return `rgba(${red},${green},${blue},1)`;
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
|
|
77
136
|
|
|
78
137
|
function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
@@ -92,7 +151,7 @@ function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
|
92
151
|
return colour;
|
|
93
152
|
}
|
|
94
153
|
|
|
95
|
-
function createPaletteAwareComponents(palette) {
|
|
154
|
+
function createPaletteAwareComponents(palette, isDark) {
|
|
96
155
|
const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
|
|
97
156
|
const autofill = {
|
|
98
157
|
'&:-webkit-autofill': {
|
|
@@ -117,7 +176,7 @@ function createPaletteAwareComponents(palette) {
|
|
|
117
176
|
]),
|
|
118
177
|
);
|
|
119
178
|
|
|
120
|
-
|
|
179
|
+
const components = {
|
|
121
180
|
MuiButton: {
|
|
122
181
|
styleOverrides: {
|
|
123
182
|
root: { transition: stateTransition },
|
|
@@ -199,6 +258,22 @@ function createPaletteAwareComponents(palette) {
|
|
|
199
258
|
styleOverrides: alertStyles,
|
|
200
259
|
},
|
|
201
260
|
};
|
|
261
|
+
|
|
262
|
+
if (isDark) {
|
|
263
|
+
for (const component of ['MuiDialog', 'MuiMenu', 'MuiPopover', 'MuiDrawer']) {
|
|
264
|
+
components[component] = {
|
|
265
|
+
styleOverrides: {
|
|
266
|
+
paper: {
|
|
267
|
+
backgroundColor: DARK_OVERLAY_SURFACE,
|
|
268
|
+
boxShadow: DARK_OVERLAY_SHADOW,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
components.MuiBackdrop = { styleOverrides: { root: { backgroundColor: DARK_SCRIM } } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return components;
|
|
202
277
|
}
|
|
203
278
|
|
|
204
279
|
/**
|
|
@@ -212,19 +287,29 @@ export function createAppTheme(appConfig = {}) {
|
|
|
212
287
|
}
|
|
213
288
|
validateStyleOverrides(appConfig.components);
|
|
214
289
|
|
|
290
|
+
const isDark = appConfig.palette.mode === 'dark';
|
|
291
|
+
const baselinePalette = isDark ? BASELINE_PALETTE_DARK : BASELINE_PALETTE;
|
|
292
|
+
|
|
215
293
|
const paletteTheme = createTheme(
|
|
216
294
|
{ palette: appConfig.palette },
|
|
217
|
-
{ palette:
|
|
295
|
+
{ palette: baselinePalette },
|
|
218
296
|
{ palette: appConfig.palette },
|
|
219
297
|
);
|
|
220
298
|
const finalPalette = paletteTheme.palette;
|
|
221
|
-
const contrastSurfaces =
|
|
299
|
+
const contrastSurfaces = isDark
|
|
300
|
+
? [finalPalette.background.default, finalPalette.background.paper]
|
|
301
|
+
: ['#FFFFFF', finalPalette.background.default];
|
|
222
302
|
const derivedStatusEntries = {};
|
|
223
303
|
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
224
304
|
if (appConfig.palette?.[status]?.main !== undefined) continue;
|
|
225
305
|
|
|
226
306
|
const current = finalPalette[status];
|
|
227
|
-
const
|
|
307
|
+
const statusSurfaces = isDark
|
|
308
|
+
? [finalPalette.background.default, current.bg]
|
|
309
|
+
: contrastSurfaces;
|
|
310
|
+
const main = isDark
|
|
311
|
+
? deriveLightenedPageColour(current.main, statusSurfaces, 4.5)
|
|
312
|
+
: deriveDarkenedPageColour(current.main, statusSurfaces, 4.5);
|
|
228
313
|
if (main !== current.main) {
|
|
229
314
|
derivedStatusEntries[status] = {
|
|
230
315
|
...current,
|
|
@@ -238,19 +323,57 @@ export function createAppTheme(appConfig = {}) {
|
|
|
238
323
|
if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
|
|
239
324
|
|
|
240
325
|
const current = finalPalette.controlBorder[state];
|
|
241
|
-
const derived =
|
|
242
|
-
?
|
|
243
|
-
|
|
326
|
+
const derived = isDark
|
|
327
|
+
? state === 'error'
|
|
328
|
+
? deriveLightenedPageColour(current, [finalPalette.background.default, finalPalette.error.bg], 3)
|
|
329
|
+
: deriveDarkAlphaColour(finalPalette.ink.primary, contrastSurfaces)
|
|
330
|
+
: state === 'error'
|
|
331
|
+
? deriveDarkenedPageColour(current, contrastSurfaces, 3)
|
|
332
|
+
: deriveAlphaPageColour(current, contrastSurfaces, 3);
|
|
244
333
|
if (derived !== current) derivedControlBorderEntries[state] = derived;
|
|
245
334
|
}
|
|
335
|
+
if (isDark && appConfig.palette?.controlBorder?.hover === undefined) {
|
|
336
|
+
const main = derivedControlBorderEntries.main || finalPalette.controlBorder.main;
|
|
337
|
+
const alpha = Number(main.match(/,([\d.]+)\)$/)?.[1] || 0);
|
|
338
|
+
const hoverAlpha = Math.min(1, Number((alpha + 0.15).toFixed(2)));
|
|
339
|
+
const [red, green, blue] = inkChannels(finalPalette.ink.primary);
|
|
340
|
+
derivedControlBorderEntries.hover = `rgba(${red},${green},${blue},${hoverAlpha.toFixed(2)})`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const primaryMain = isDark
|
|
344
|
+
? deriveLightenedPageColour(finalPalette.primary.main, [finalPalette.background.default], 4.5)
|
|
345
|
+
: finalPalette.primary.main;
|
|
346
|
+
const primaryContrastText = isDark
|
|
347
|
+
? (getContrastRatio('#FFFFFF', primaryMain) >= getContrastRatio(finalPalette.background.default, primaryMain)
|
|
348
|
+
? '#FFFFFF'
|
|
349
|
+
: finalPalette.background.default)
|
|
350
|
+
: finalPalette.primary.contrastText;
|
|
351
|
+
const derivedSeries = isDark && appConfig.palette?.dataSeries?.categorical === undefined
|
|
352
|
+
? finalPalette.dataSeries.categorical.map((colour) => (
|
|
353
|
+
deriveLightenedPageColour(colour, [finalPalette.background.paper], 3)
|
|
354
|
+
))
|
|
355
|
+
: finalPalette.dataSeries.categorical;
|
|
246
356
|
|
|
247
357
|
const computedPalette = {
|
|
248
358
|
...derivedStatusEntries,
|
|
249
359
|
controlBorder: {
|
|
250
360
|
...finalPalette.controlBorder,
|
|
251
361
|
...derivedControlBorderEntries,
|
|
252
|
-
focus:
|
|
362
|
+
focus: isDark
|
|
363
|
+
? (appConfig.palette?.controlBorder?.focus
|
|
364
|
+
?? deriveLightenedColour(primaryMain, contrastSurfaces))
|
|
365
|
+
: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
|
|
366
|
+
},
|
|
367
|
+
primary: {
|
|
368
|
+
...finalPalette.primary,
|
|
369
|
+
// A lightened main needs its own light/dark shades, or hover states keep
|
|
370
|
+
// the shades of the colour that was replaced.
|
|
371
|
+
...(isDark && primaryMain !== finalPalette.primary.main
|
|
372
|
+
? withMainShades(primaryMain, primaryContrastText)
|
|
373
|
+
: { main: primaryMain }),
|
|
374
|
+
contrastText: primaryContrastText,
|
|
253
375
|
},
|
|
376
|
+
dataSeries: { categorical: derivedSeries },
|
|
254
377
|
};
|
|
255
378
|
const resolvedPalette = { ...finalPalette, ...computedPalette };
|
|
256
379
|
const fontFamily = appConfig.typography?.fontFamily || BASELINE_STATIC.typography.fontFamily;
|
|
@@ -266,20 +389,24 @@ export function createAppTheme(appConfig = {}) {
|
|
|
266
389
|
...BASELINE_STATIC.fontLoading,
|
|
267
390
|
weights: [...BASELINE_STATIC.fontLoading.weights],
|
|
268
391
|
},
|
|
392
|
+
shadow: isDark ? { ...BASELINE_STATIC.shadow, overlay: DARK_OVERLAY_SHADOW } : BASELINE_STATIC.shadow,
|
|
269
393
|
palette: resolvedPalette,
|
|
270
394
|
themeCompleteness: {
|
|
271
395
|
baseline: true,
|
|
272
|
-
exemptions:
|
|
396
|
+
exemptions: (isDark
|
|
397
|
+
? BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK
|
|
398
|
+
: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS).map((exemption) => ({
|
|
273
399
|
...exemption,
|
|
274
400
|
})),
|
|
275
401
|
},
|
|
276
402
|
},
|
|
277
403
|
{
|
|
278
|
-
components: createPaletteAwareComponents(resolvedPalette),
|
|
404
|
+
components: createPaletteAwareComponents(resolvedPalette, isDark),
|
|
279
405
|
},
|
|
280
406
|
appConfig,
|
|
281
407
|
{
|
|
282
408
|
palette: {
|
|
409
|
+
...(isDark ? computedPalette : {}),
|
|
283
410
|
dataSeries: {
|
|
284
411
|
categorical: [...resolvedPalette.dataSeries.categorical],
|
|
285
412
|
},
|
|
@@ -294,10 +294,16 @@ function contrastFindings(theme) {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
const dark = theme.palette?.mode === 'dark';
|
|
298
|
+
const backgrounds = dark
|
|
299
|
+
? {
|
|
300
|
+
paper: theme.palette?.background?.paper,
|
|
301
|
+
page: theme.palette?.background?.default,
|
|
302
|
+
}
|
|
303
|
+
: {
|
|
304
|
+
white: '#FFFFFF',
|
|
305
|
+
page: theme.palette?.background?.default,
|
|
306
|
+
};
|
|
301
307
|
for (const status of STATUS_KEYS) {
|
|
302
308
|
const main = theme.palette?.[status]?.main;
|
|
303
309
|
if (!main) continue;
|
package/src/theme/tokens.js
CHANGED
|
@@ -113,6 +113,73 @@ export const BASELINE_PALETTE = {
|
|
|
113
113
|
},
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
export const DARK_OVERLAY_SURFACE = '#232A31';
|
|
117
|
+
export const DARK_OVERLAY_SHADOW = '0 8px 28px rgba(0,0,0,.45)';
|
|
118
|
+
export const DARK_SCRIM = 'rgba(0,0,0,.55)';
|
|
119
|
+
|
|
120
|
+
export const BASELINE_PALETTE_DARK = {
|
|
121
|
+
mode: 'dark',
|
|
122
|
+
ink: {
|
|
123
|
+
primary: '#E6EBEF',
|
|
124
|
+
secondary: '#AAB5BF',
|
|
125
|
+
muted: '#87919A',
|
|
126
|
+
},
|
|
127
|
+
text: {
|
|
128
|
+
primary: '#E6EBEF',
|
|
129
|
+
secondary: '#AAB5BF',
|
|
130
|
+
disabled: '#87919A',
|
|
131
|
+
},
|
|
132
|
+
background: {
|
|
133
|
+
default: '#14181B',
|
|
134
|
+
paper: '#1B2126',
|
|
135
|
+
subtle: '#20262C',
|
|
136
|
+
},
|
|
137
|
+
divider: '#2B333A',
|
|
138
|
+
controlBorder: {
|
|
139
|
+
// These are replaced by the dark alpha derivation in createAppTheme.
|
|
140
|
+
main: 'rgba(230,235,239,0)',
|
|
141
|
+
hover: 'rgba(230,235,239,0)',
|
|
142
|
+
error: '#E58B80',
|
|
143
|
+
},
|
|
144
|
+
success: {
|
|
145
|
+
...withMainShades('#7BC496', '#14181B'),
|
|
146
|
+
text: '#7BC496',
|
|
147
|
+
fill: '#7BC496',
|
|
148
|
+
fillText: '#14181B',
|
|
149
|
+
bg: '#1C2A22',
|
|
150
|
+
},
|
|
151
|
+
warning: {
|
|
152
|
+
...withMainShades('#D8BD7E', '#14181B'),
|
|
153
|
+
text: '#D8BD7E',
|
|
154
|
+
fill: '#D8BD7E',
|
|
155
|
+
fillText: '#14181B',
|
|
156
|
+
bg: '#2A2418',
|
|
157
|
+
},
|
|
158
|
+
error: {
|
|
159
|
+
...withMainShades('#E58B80', '#14181B'),
|
|
160
|
+
text: '#E58B80',
|
|
161
|
+
fill: '#E58B80',
|
|
162
|
+
fillText: '#14181B',
|
|
163
|
+
bg: '#3A2320',
|
|
164
|
+
},
|
|
165
|
+
info: {
|
|
166
|
+
...withMainShades('#8FB8E0', '#14181B'),
|
|
167
|
+
text: '#8FB8E0',
|
|
168
|
+
fill: '#8FB8E0',
|
|
169
|
+
fillText: '#14181B',
|
|
170
|
+
bg: '#1A2633',
|
|
171
|
+
},
|
|
172
|
+
stale: {
|
|
173
|
+
text: '#AAB5BF',
|
|
174
|
+
fill: '#AAB5BF',
|
|
175
|
+
fillText: '#14181B',
|
|
176
|
+
bg: '#20262C',
|
|
177
|
+
},
|
|
178
|
+
dataSeries: {
|
|
179
|
+
categorical: SERIES_COLOURS,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
116
183
|
export const OVERLAY_SHADOW =
|
|
117
184
|
'0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
118
185
|
|
|
@@ -399,3 +466,8 @@ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
|
399
466
|
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
400
467
|
})),
|
|
401
468
|
];
|
|
469
|
+
|
|
470
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK =
|
|
471
|
+
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.filter(
|
|
472
|
+
({ surface }) => surface !== 'palette.background.paper',
|
|
473
|
+
);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import i18next from 'i18next';
|
|
5
|
+
import { I18nextProvider } from 'react-i18next';
|
|
6
|
+
|
|
7
|
+
import apiClient from '../src/auth/apiClient';
|
|
8
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
9
|
+
import { checkKitIntegration } from '../src/testing/checkKitIntegration';
|
|
10
|
+
import { uiCoreTranslations } from '../src/i18n/uiCoreTranslations';
|
|
11
|
+
import { LoginPage } from '../src/pages/LoginPage';
|
|
12
|
+
|
|
13
|
+
const languages = ['de', 'en', 'fr', 'sw'];
|
|
14
|
+
const authMethods = {
|
|
15
|
+
password_login: true,
|
|
16
|
+
password_reset: false,
|
|
17
|
+
signup: false,
|
|
18
|
+
signup_modes: [],
|
|
19
|
+
social_login: false,
|
|
20
|
+
passkey_login: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function resources() {
|
|
24
|
+
return Object.fromEntries(languages.map((language) => [language, {
|
|
25
|
+
translation: Object.fromEntries(
|
|
26
|
+
Object.entries(uiCoreTranslations).map(([key, values]) => [key, values[language]]),
|
|
27
|
+
),
|
|
28
|
+
}]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeWrapper(instance, authMethodsOverride = authMethods) {
|
|
32
|
+
return function FixtureWrapper({ children }) {
|
|
33
|
+
return (
|
|
34
|
+
<I18nextProvider i18n={instance}>
|
|
35
|
+
<AuthContext.Provider value={{
|
|
36
|
+
user: null,
|
|
37
|
+
loading: false,
|
|
38
|
+
login: vi.fn(),
|
|
39
|
+
authMethods: authMethodsOverride,
|
|
40
|
+
}}>
|
|
41
|
+
{children}
|
|
42
|
+
</AuthContext.Provider>
|
|
43
|
+
</I18nextProvider>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
50
|
+
await i18next.init({
|
|
51
|
+
lng: 'en',
|
|
52
|
+
fallbackLng: false,
|
|
53
|
+
supportedLngs: languages,
|
|
54
|
+
resources: resources(),
|
|
55
|
+
keySeparator: false,
|
|
56
|
+
nsSeparator: false,
|
|
57
|
+
interpolation: { escapeValue: false },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await i18next.changeLanguage('en');
|
|
63
|
+
for (const language of languages) {
|
|
64
|
+
i18next.removeResourceBundle(language, 'translation');
|
|
65
|
+
i18next.addResourceBundle(language, 'translation', resources()[language].translation, true, true);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('checkKitIntegration', () => {
|
|
70
|
+
it('passes for a correctly wired app fixture', async () => {
|
|
71
|
+
const { findings } = await checkKitIntegration({
|
|
72
|
+
i18n: i18next,
|
|
73
|
+
wrapper: makeWrapper(i18next),
|
|
74
|
+
pages: [LoginPage],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(findings).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not flag a legitimate short lowercase translated word as a raw backend code', async () => {
|
|
81
|
+
// Auth.LOGIN_OR's German value is literally "oder" -- all-lowercase, no
|
|
82
|
+
// spaces, length 4 -- the exact shape a naive raw-code heuristic would
|
|
83
|
+
// false-positive on. Render LoginForm's sign-up/forgot-password divider
|
|
84
|
+
// (onSignUp truthy) in German to actually exercise that rendered text.
|
|
85
|
+
await i18next.changeLanguage('de');
|
|
86
|
+
const { findings } = await checkKitIntegration({
|
|
87
|
+
i18n: i18next,
|
|
88
|
+
wrapper: makeWrapper(i18next, { ...authMethods, signup: true, signup_modes: ['self_signup_open'] }),
|
|
89
|
+
pages: [LoginPage],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(findings).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports one catalogue finding for one missing language key', async () => {
|
|
96
|
+
const key = 'Auth.field.email.invalid';
|
|
97
|
+
const french = { ...resources().fr.translation };
|
|
98
|
+
delete french[key];
|
|
99
|
+
i18next.removeResourceBundle('fr', 'translation');
|
|
100
|
+
i18next.addResourceBundle('fr', 'translation', french, true, true);
|
|
101
|
+
|
|
102
|
+
const { findings } = await checkKitIntegration({
|
|
103
|
+
i18n: i18next,
|
|
104
|
+
wrapper: makeWrapper(i18next),
|
|
105
|
+
pages: [],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(findings).toHaveLength(1);
|
|
109
|
+
expect(findings[0]).toMatchObject({ check: 'catalogue' });
|
|
110
|
+
expect(findings[0].reason).toContain(`${key} for language fr`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still reports a missing key when the consumer app configures a fallback language', async () => {
|
|
114
|
+
// A consumer typically sets fallbackLng (e.g. "en"), not "false" like the
|
|
115
|
+
// fixture above. Without forcing fallbackLng: false per-call, i18next's
|
|
116
|
+
// exists() would resolve the missing "fr" key through the fallback and
|
|
117
|
+
// read as present, masking the real gap this check exists to catch.
|
|
118
|
+
const key = 'Auth.field.email.invalid';
|
|
119
|
+
const french = { ...resources().fr.translation };
|
|
120
|
+
delete french[key];
|
|
121
|
+
i18next.removeResourceBundle('fr', 'translation');
|
|
122
|
+
i18next.addResourceBundle('fr', 'translation', french, true, true);
|
|
123
|
+
i18next.options.fallbackLng = 'en';
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const { findings } = await checkKitIntegration({
|
|
127
|
+
i18n: i18next,
|
|
128
|
+
wrapper: makeWrapper(i18next),
|
|
129
|
+
pages: [],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(findings).toHaveLength(1);
|
|
133
|
+
expect(findings[0]).toMatchObject({ check: 'catalogue' });
|
|
134
|
+
expect(findings[0].reason).toContain(`${key} for language fr`);
|
|
135
|
+
} finally {
|
|
136
|
+
i18next.options.fallbackLng = false;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports when the app passes a separate i18next instance', async () => {
|
|
141
|
+
const separate = i18next.createInstance();
|
|
142
|
+
await separate.init({
|
|
143
|
+
lng: 'en',
|
|
144
|
+
fallbackLng: false,
|
|
145
|
+
supportedLngs: languages,
|
|
146
|
+
resources: resources(),
|
|
147
|
+
keySeparator: false,
|
|
148
|
+
nsSeparator: false,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const { findings } = await checkKitIntegration({
|
|
152
|
+
i18n: separate,
|
|
153
|
+
wrapper: makeWrapper(separate),
|
|
154
|
+
pages: [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(findings.some(({ check }) => check === 'instance')).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports a page when the consumer wrapper throws', async () => {
|
|
161
|
+
function ThrowingWrapper() {
|
|
162
|
+
throw new Error('provider exploded');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const before = apiClient.defaults.adapter;
|
|
166
|
+
const { findings } = await checkKitIntegration({
|
|
167
|
+
i18n: i18next,
|
|
168
|
+
wrapper: ThrowingWrapper,
|
|
169
|
+
pages: [LoginPage],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(findings.some(({ check, reason }) => check === 'render' && reason.includes('LoginPage'))).toBe(true);
|
|
173
|
+
expect(apiClient.defaults.adapter).toBe(before);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('passes the exact invalid-email resolver regression through the real login page', async () => {
|
|
177
|
+
const { findings } = await checkKitIntegration({
|
|
178
|
+
i18n: i18next,
|
|
179
|
+
wrapper: makeWrapper(i18next),
|
|
180
|
+
pages: [],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(findings).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('restores the transport adapter after a successful check', async () => {
|
|
187
|
+
const before = apiClient.defaults.adapter;
|
|
188
|
+
await checkKitIntegration({
|
|
189
|
+
i18n: i18next,
|
|
190
|
+
wrapper: makeWrapper(i18next),
|
|
191
|
+
pages: [],
|
|
192
|
+
});
|
|
193
|
+
expect(apiClient.defaults.adapter).toBe(before);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('no longer intercepts a real request once the check has finished', async () => {
|
|
197
|
+
// Proves the restore is functional, not just reference-equal: a request
|
|
198
|
+
// made AFTER the check completes must reach the app's own adapter, not
|
|
199
|
+
// the check's stub (which would reject it as "blocked" or fake a login
|
|
200
|
+
// failure for the allauth login path).
|
|
201
|
+
const sentinel = vi.fn().mockResolvedValue({ data: 'real-response', status: 200, headers: {}, config: {} });
|
|
202
|
+
const before = apiClient.defaults.adapter;
|
|
203
|
+
apiClient.defaults.adapter = sentinel;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await checkKitIntegration({
|
|
207
|
+
i18n: i18next,
|
|
208
|
+
wrapper: makeWrapper(i18next),
|
|
209
|
+
pages: [],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const response = await apiClient.get('/api/some/path');
|
|
213
|
+
expect(sentinel).toHaveBeenCalled();
|
|
214
|
+
expect(response.data).toBe('real-response');
|
|
215
|
+
} finally {
|
|
216
|
+
apiClient.defaults.adapter = before;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|