220 lines
8.3 KiB
JavaScript
220 lines
8.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import vm from 'node:vm';
|
|
import vue from '@vitejs/plugin-vue';
|
|
import { createServer } from 'vite';
|
|
import { createSSRApp, h } from 'vue';
|
|
import { renderToString } from 'vue/server-renderer';
|
|
|
|
const server = await createServer({
|
|
configFile: false,
|
|
plugins: [vue()],
|
|
resolve: {
|
|
alias: {
|
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
|
},
|
|
},
|
|
server: { middlewareMode: true, hmr: false, watch: null },
|
|
appType: 'custom',
|
|
});
|
|
|
|
function browserState(cookie, stored, dark, storageDenied = false) {
|
|
const root = { dataset: { appearance: cookie }, style: {}, dark: false };
|
|
root.classList = {
|
|
toggle: (_, value) => {
|
|
root.dark = value;
|
|
},
|
|
};
|
|
const listeners = new Map();
|
|
const storage = new Map(stored === null ? [] : [['appearance', stored]]);
|
|
const media = {
|
|
matches: dark,
|
|
addEventListener: (_, listener) => listeners.set('media', listener),
|
|
removeEventListener: () => listeners.delete('media'),
|
|
};
|
|
return {
|
|
document: { documentElement: root, cookie: '' },
|
|
window: {
|
|
matchMedia: () => media,
|
|
localStorage: {
|
|
getItem: (key) => {
|
|
if (storageDenied) {
|
|
throw new Error('Storage denied');
|
|
}
|
|
return storage.get(key) ?? null;
|
|
},
|
|
setItem: (key, value) => {
|
|
if (storageDenied) {
|
|
throw new Error('Storage denied');
|
|
}
|
|
storage.set(key, value);
|
|
},
|
|
},
|
|
addEventListener: (_, listener) =>
|
|
listeners.set('storage', listener),
|
|
removeEventListener: () => listeners.delete('storage'),
|
|
},
|
|
root,
|
|
media,
|
|
listeners,
|
|
storage,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const { createAppearance, normalizeAppearance } =
|
|
await server.ssrLoadModule(
|
|
'/resources/js/composables/useAppearance.ts',
|
|
);
|
|
const { createLocalization } = await server.ssrLoadModule(
|
|
'/resources/js/lib/localization.ts',
|
|
);
|
|
const { default: AppearanceTabs } = await server.ssrLoadModule(
|
|
'/resources/js/components/AppearanceTabs.vue',
|
|
);
|
|
|
|
const renderTheme = (value) =>
|
|
renderToString(
|
|
createSSRApp({ render: () => h(AppearanceTabs) })
|
|
.use(createLocalization())
|
|
.use(createAppearance(value)),
|
|
);
|
|
const [light, dark] = await Promise.all([
|
|
renderTheme('light'),
|
|
renderTheme('dark'),
|
|
]);
|
|
const selectedButton = (html) =>
|
|
html.match(
|
|
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
|
)[1];
|
|
assert.match(selectedButton(light), />Light</);
|
|
assert.match(selectedButton(dark), />Dark</);
|
|
assert.match(light, /role="group" aria-label="Color theme"/);
|
|
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
|
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
|
assert.equal(normalizeAppearance('unexpected'), 'system');
|
|
|
|
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
|
const bootstrap = blade.match(/<script>([\s\S]*?)<\/script>/)[1];
|
|
for (const [cookie, stored, osDark, denied, expected, preference] of [
|
|
['system', null, false, false, false, 'system'],
|
|
['system', null, true, false, true, 'system'],
|
|
['dark', null, false, false, true, 'dark'],
|
|
['light', null, true, false, false, 'light'],
|
|
['light', 'dark', false, false, true, 'dark'],
|
|
['dark', null, false, true, true, 'dark'],
|
|
['light', 'invalid', true, false, true, 'system'],
|
|
]) {
|
|
const state = browserState(cookie, stored, osDark, denied);
|
|
vm.runInNewContext(bootstrap, state);
|
|
assert.equal(state.root.dark, expected);
|
|
assert.equal(state.root.dataset.appearance, preference);
|
|
assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light');
|
|
}
|
|
|
|
for (const denied of [false, true]) {
|
|
const state = browserState('system', null, false, denied);
|
|
globalThis.window = state.window;
|
|
globalThis.document = state.document;
|
|
const theme = createAppearance();
|
|
let cleanup;
|
|
theme.install({
|
|
provide() {},
|
|
onUnmount(callback) {
|
|
cleanup = callback;
|
|
},
|
|
});
|
|
assert.equal(theme.resolvedAppearance.value, 'light');
|
|
state.listeners.get('media')({ matches: true });
|
|
assert.equal(theme.resolvedAppearance.value, 'dark');
|
|
assert.equal(state.root.dark, true);
|
|
theme.updateAppearance('light');
|
|
assert.equal(state.root.dark, false);
|
|
assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/);
|
|
state.listeners.get('media')({ matches: false });
|
|
state.listeners.get('media')({ matches: true });
|
|
assert.equal(theme.resolvedAppearance.value, 'light');
|
|
theme.updateAppearance('system');
|
|
assert.equal(theme.resolvedAppearance.value, 'dark');
|
|
state.listeners.get('storage')({
|
|
key: 'appearance',
|
|
newValue: 'light',
|
|
});
|
|
assert.equal(theme.appearance.value, 'light');
|
|
state.listeners.get('storage')({ key: null, newValue: null });
|
|
assert.equal(theme.appearance.value, 'system');
|
|
if (!denied) {
|
|
assert.equal(state.storage.get('appearance'), 'system');
|
|
}
|
|
cleanup();
|
|
assert.equal(state.listeners.size, 0);
|
|
delete globalThis.window;
|
|
delete globalThis.document;
|
|
}
|
|
|
|
const css = await readFile('resources/css/theme.css', 'utf8');
|
|
function luminance(hex) {
|
|
const values = hex
|
|
.match(/[a-f\d]{2}/gi)
|
|
.map((part) => parseInt(part, 16) / 255)
|
|
.map((value) =>
|
|
value <= 0.04045
|
|
? value / 12.92
|
|
: ((value + 0.055) / 1.055) ** 2.4,
|
|
);
|
|
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
|
|
}
|
|
function contrast(first, second) {
|
|
const values = [luminance(first), luminance(second)].sort(
|
|
(a, b) => b - a,
|
|
);
|
|
return (values[0] + 0.05) / (values[1] + 0.05);
|
|
}
|
|
for (const selector of [':root', '.dark']) {
|
|
const declarations = css
|
|
.slice(css.indexOf(selector))
|
|
.match(/\{([^}]+)\}/)[1];
|
|
const tokens = Object.fromEntries(
|
|
[...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map(
|
|
(match) => [match[1], match[2]],
|
|
),
|
|
);
|
|
for (const [background, foreground] of [
|
|
['background', 'foreground'],
|
|
['card', 'card-foreground'],
|
|
['popover', 'popover-foreground'],
|
|
['primary', 'primary-foreground'],
|
|
['secondary', 'secondary-foreground'],
|
|
['muted', 'muted-foreground'],
|
|
['accent', 'accent-foreground'],
|
|
['destructive', 'destructive-foreground'],
|
|
['card', 'destructive'],
|
|
['brand', 'brand-foreground'],
|
|
['success', 'success-foreground'],
|
|
['warning', 'warning-foreground'],
|
|
['info', 'info-foreground'],
|
|
['sidebar-background', 'sidebar-foreground'],
|
|
['sidebar-accent', 'sidebar-accent-foreground'],
|
|
['sidebar-primary', 'sidebar-primary-foreground'],
|
|
]) {
|
|
assert.ok(
|
|
contrast(tokens[background], tokens[foreground]) >= 4.5,
|
|
selector + ': ' + background + '/' + foreground,
|
|
);
|
|
}
|
|
assert.ok(contrast(tokens.background, tokens.ring) >= 3);
|
|
assert.ok(contrast(tokens.card, tokens.input) >= 3);
|
|
}
|
|
const appCss = await readFile('resources/css/app.css', 'utf8');
|
|
assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/);
|
|
assert.match(appCss, /animation-duration: 0.01ms !important/);
|
|
assert.match(appCss, /transition-duration: 0.01ms !important/);
|
|
assert.match(appCss, /scroll-behavior: auto !important/);
|
|
console.log('Appearance behavior checks passed.');
|
|
} finally {
|
|
delete globalThis.window;
|
|
delete globalThis.document;
|
|
await server.close();
|
|
}
|