feat(active-uix)!: el boot lo compila el build del SITIO con su propio esquema, y lo que UIX escribe en <html dir> lleva marca de propiedad (fila 2 del boot, F1 del cierre)
F1 del plan de cierre del framework. Cierra el último trabajo del eje boot.
EL DEFECTO. El boot por defecto compilaba un catálogo de preferencias de UN
solo idioma (createDefaultUixPrefsSchema), y la raíz sembraba su entorno
leyendo el <html dir> que el boot acababa de escribir: un usuario árabe de
una app multiidioma no veía un parpadeo, se quedaba en LTR TODA la sesión,
porque el runtime tomaba la salida del boot como si la hubiera declarado la
página.
EL COMPILADOR POR SITIO. Una sola fusión, composeUixPrefsSchema, la consumen
createActiveUix y el boot: no puede derivar. El generador acepta --schema y
--out (el especificador boot/boot-schema.ts apunta al esquema del sitio o a
boot/default-schema.ts); los guards (sin runas, techo de tamaño, ASCII, sin
</script, valores string por atributo) son errores del COMPILADOR con mensajes
para el consumidor, porque ahora el artefacto lo produce el sitio. Un esquema
que importa el barrel $prefs se rechaza nombrándolo. scripts/uix-boot-check.ts
es el guard de rancidez: plugin de Vite que tumba el build con un artefacto
viejo (probado con vite build real) y función para CI. Delta cero por esquema:
por defecto, multiidioma con árabe, ejes redefinidos y esquema parcial.
LA MARCA DE PROPIEDAD, diseño firmado por el autor. El canon de dirección dice
que <html dir> es una proyección y nunca una fuente, con una sola excepción: el
dir del AUTOR en la plantilla o el servidor. El boot rompió la premisa de esa
excepción. Todo lo que UIX escribe en <html dir> lleva ahora
PREFS_DIR_PROJECTED_ATTR (el boot con valor «boot», cada proyección del runtime
con un token de instancia); la semilla solo adopta un dir SIN marca. Un dir
escrito por script no es una fuente: en ejecución la dirección se afirma con
prefs.setIntent('direction') o options.prefs.environment, que ganan a la
semilla. Se descartaron, midiendo, la marca con valor (cierra el script y
congela la dirección al navegar entre layouts) y la inferencia por valor.
dispose solo retira lo que todavía es SUYO: SvelteKit crea la raíz del layout
nuevo ANTES de destruir la vieja (medido), y un retiro a ciegas dejaba el
<html> de la raíz nueva sin dir, lang ni data-motion/sound/haptic (medido hoy:
los nueve atributos a null). Mismo principio que el unstamp de sema, que
comprueba data-event-id.
esbuild se declara como devDependency EXACTA 0.27.4: los bytes del boot y su
hash dependen del minificador, y una subida dentro de un rango rompería la
sincronía. Boot 15 198 B, sha256-hsqdGYcrRu3oEc0Q3G/A67ApQT3q9c/vT9zMDgxROg8=
(el hash se mueve: firmado por el autor).
VERIFICACIÓN. Constructor Opus en cuatro rondas y adversarial Opus en dos
pasadas independientes, con sus reproducciones repetidas tras cada cierre:
en Chromium, entrar en árabe y pasar a inglés y a español sigue al idioma, y al
revés también; el dir de plantilla gana antes y después de hidratar y en una
raíz recreada; una raíz recreada sobre una viva sigue al idioma con y sin boot;
tras create b → destroy a, <html> conserva lo que proyectó b. Diez defectos
declarados por el adversarial, cerrados (D1–D10): marca de propiedad, esquema
parcial que estampaba "undefined", vigilante de dev mudo, plugin sin test,
receta de CI que no cargaba en jsdom, cifras y prosa, y un vite build real que
se cae cuando el boot no compila. Mutaciones en rojo, restauradas byte a byte:
la proyección no marca · dispose sin guard de dueño (también repetida por el
coordinador: 3 rojos) · la semilla compara valor en vez de presencia · marcar
el dir del autor. Suite entera 463 ficheros / 5 437 tests, exit 0 · check 0 errores en src/ y en scripts/ (89 en web/, ledger intacto) ·
docs:check 0/0 · generate:boot dos veces byte-idéntico.
LO QUE NO CIERRA (al ledger de cierre): (1) PREEXISTENTE — en la misma
navegación entre layouts, el ActiveEidos.dispose de la raíz vieja sigue
retirando data-theme/mode/density/scaling de la raíz nueva; exige cambiar el
dispose de eidos, otra capa. (2) La propiedad de los atributos proyectados
cuelga de la marca de dir: si la plantilla declara la dirección, dispose deja
lang y data-motion puestos cuando la raíz se desmonta sin sustituta
(benigno). (3) Sin boot, un script que escribe dir antes de la primera raíz es
indistinguible de la plantilla y se adopta. (4) El vigilante de dev no
reacciona a ficheros nuevos ni a inputs fuera del root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
parent
086422e637
commit
29e581c721
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* uix-boot-check — the guard that breaks the build when the compiled boot
|
||||
* on disk no longer describes the source it came from.
|
||||
*
|
||||
* The whole boot axis fails SILENTLY by construction. The tag is wrapped
|
||||
* in a mute `try`/`catch` (it runs in `<head>`, before anything can
|
||||
* report), a stale artifact stamps values that are merely OLD rather than
|
||||
* invalid, and a browser that blocks the script leaves no trace in the
|
||||
* page. So the only guard worth having is one that stops the build: a
|
||||
* site that edits its preference schema and forgets to recompile must
|
||||
* learn it from `vite build`, not from a user in Cairo reading an LTR
|
||||
* page. (`createActiveUix`'s dev audit is the other half — it names the
|
||||
* axis that moved, in the browser, after the fact.)
|
||||
*
|
||||
* It lives in `scripts/` and not under `src/uix` on purpose: it imports
|
||||
* esbuild and Vite's plugin type, which are the HOST's build tools. The
|
||||
* framework does not ship its consumer's bundler.
|
||||
*
|
||||
* Two shapes, one check:
|
||||
* - `uixBootCheck()` — a Vite plugin. `buildStart` recompiles with the
|
||||
* site's own configuration and fails the build on any difference. In
|
||||
* dev it WARNS instead (a dev server that refuses to start over a
|
||||
* stale cosmetic artifact helps nobody) and registers every input the
|
||||
* compile read, so editing a dimension re-runs the check there and
|
||||
* then instead of waiting for a deploy. That doctrine covers a compile
|
||||
* that FAILS outright too — a half-typed schema module is the most
|
||||
* ordinary state a watched file is ever in, and it must cost a line in
|
||||
* the log, not the session.
|
||||
* - `checkUixBootArtifact()` — the same check as a promise, for a site
|
||||
* with no Vite (or a CI job that prefers a test over a build).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
import { BOOT_OUTPUT, compileBoot, renderBootModule } from './generate-boot.ts';
|
||||
|
||||
export interface UixBootCheckOptions {
|
||||
/**
|
||||
* The module exporting `bootPrefsSchema` the artifact was compiled
|
||||
* with — the same path passed to `generate-boot --schema`. Omitted,
|
||||
* the framework's default schema.
|
||||
*/
|
||||
readonly schema?: string;
|
||||
/** The artifact on disk. Defaults to the framework's own. */
|
||||
readonly out?: string;
|
||||
}
|
||||
|
||||
export interface UixBootCheckResult {
|
||||
readonly ok: boolean;
|
||||
/** Why it is stale, in the words a site can act on. Absent when `ok`. */
|
||||
readonly reason?: string;
|
||||
/** Every source file the compile read — what dev watches. */
|
||||
readonly inputs: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompile and compare. The comparison is over the artifact's TEXT, not
|
||||
* over the hash alone: the hash lives in the same file, and a check that
|
||||
* only compared hashes would pass on a file where someone edited the
|
||||
* script and left the hash — the exact pair that makes a browser block
|
||||
* the tag.
|
||||
*/
|
||||
export async function checkUixBootArtifact(
|
||||
options: UixBootCheckOptions = {},
|
||||
// Filled as the compile reads, and still filled when it throws — see
|
||||
// `compileBoot`.
|
||||
read = new Set<string>()
|
||||
): Promise<UixBootCheckResult> {
|
||||
const out = options.out === undefined ? BOOT_OUTPUT : resolve(options.out);
|
||||
const schema = options.schema === undefined ? undefined : resolve(options.schema);
|
||||
const { script, inputs } = await compileBoot({ schema }, read);
|
||||
const expected = renderBootModule(script);
|
||||
|
||||
let actual: string;
|
||||
try {
|
||||
// LF: the artifact is written with LF and a checkout with
|
||||
// `core.autocrlf` must not be read as a stale one.
|
||||
actual = readFileSync(out, 'utf8').replace(/\r\n/g, '\n');
|
||||
} catch {
|
||||
return { ok: false, reason: `no compiled boot at ${out} — run the boot compiler`, inputs };
|
||||
}
|
||||
|
||||
if (actual === expected) return { ok: true, inputs };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `the compiled boot at ${out} is stale — it no longer matches ${
|
||||
schema === undefined ? 'the framework schema' : relative(process.cwd(), schema)
|
||||
} and the modules it imports. Recompile it.`,
|
||||
inputs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A compile that never produced a result — almost always a syntax error
|
||||
* the site is halfway through typing into its schema module. It is a
|
||||
* different event from a stale artifact and gets its own sentence,
|
||||
* because the artifact on disk may well be fine.
|
||||
*/
|
||||
function compileFailure(error: unknown): string {
|
||||
return `could not compile the boot: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
export function uixBootCheck(options: UixBootCheckOptions = {}): Plugin {
|
||||
let serve = false;
|
||||
// The files the last compile READ. A file that is NOT one of them cannot
|
||||
// make the artifact stale, and a file that becomes one can only do it
|
||||
// through an edit to a file that already is — so membership is enough to
|
||||
// decide whether a save is worth a recompile.
|
||||
//
|
||||
// That holds for a compile that FAILED too, as long as the set is the one
|
||||
// it read before it broke: the file that broke it is in there, and so is
|
||||
// every file that imports it. Measured, twice: a set kept only from
|
||||
// SUCCESSFUL compiles was empty on a dev server that started on a
|
||||
// half-typed schema, and never held a module the schema imported broken
|
||||
// for the first time — either way the fix was discarded as "not an input"
|
||||
// and the guard stayed silent. Checking every save instead while broken
|
||||
// was measured too: ten duplicate warnings from a SvelteKit start-up,
|
||||
// which writes its own files.
|
||||
let inputs = new Set<string>();
|
||||
|
||||
return {
|
||||
name: 'uix-boot-check',
|
||||
|
||||
configResolved(config) {
|
||||
serve = config.command === 'serve';
|
||||
},
|
||||
|
||||
async buildStart() {
|
||||
const read = new Set<string>();
|
||||
let result: UixBootCheckResult | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
result = await checkUixBootArtifact(options, read);
|
||||
} catch (error) {
|
||||
// In dev this is a warning like any other: a guard over a
|
||||
// cosmetic artifact must never be the reason a dev server
|
||||
// refuses to start, and the site is about to see the same
|
||||
// syntax error from Vite itself. On a build there is nothing
|
||||
// to recover to.
|
||||
if (!serve) throw error;
|
||||
failure = error;
|
||||
}
|
||||
inputs = read;
|
||||
// In dev the input files are registered so a later edit re-runs
|
||||
// the check; on a build there is no "later", so a difference is
|
||||
// the end of the build.
|
||||
if (serve) for (const input of read) this.addWatchFile(input);
|
||||
if (result === undefined) {
|
||||
this.warn(compileFailure(failure));
|
||||
return;
|
||||
}
|
||||
if (result.ok) return;
|
||||
if (serve) this.warn(result.reason!);
|
||||
else this.error(result.reason!);
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
server.watcher.on('change', (file) => {
|
||||
if (!inputs.has(resolve(file))) return;
|
||||
const read = new Set<string>();
|
||||
void checkUixBootArtifact(options, read)
|
||||
.then((result) => {
|
||||
inputs = new Set(result.inputs);
|
||||
if (result.ok) return;
|
||||
server.config.logger.warn(`[uix-boot-check] ${result.reason}`);
|
||||
})
|
||||
// Without this the rejection is unhandled and Node 24 ENDS
|
||||
// THE PROCESS: one typo in a schema module and the session
|
||||
// is over. (The compile itself no longer throws from
|
||||
// outside a promise — see `inputCollector` in
|
||||
// `generate-boot.ts` — but a guard that watches a file the
|
||||
// user is editing has to survive every state that file
|
||||
// passes through.)
|
||||
.catch((error: unknown) => {
|
||||
inputs = read;
|
||||
server.config.logger.warn(`[uix-boot-check] ${compileFailure(error)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,289 @@
|
||||
/**
|
||||
* The staleness guard, judged on the three states a site can be in: in
|
||||
* sync, compiled from another schema, and with no artifact at all.
|
||||
*
|
||||
* The first case is also the framework's own gate — `npm run test` fails
|
||||
* when `src/uix/active-uix/generated/boot.js` no longer describes the
|
||||
* modules it was compiled from. It overlaps `generated-boot.test.ts` on
|
||||
* purpose and does not duplicate it: that suite compares the two
|
||||
* CONSTANTS a site imports, this one compares the FILE the compiler
|
||||
* writes, which is what a build recompiles and diffs.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { build, createLogger, createServer, type Logger, type ViteDevServer } from 'vite';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { compileBoot, renderBootModule } from '../../../../scripts/generate-boot.ts';
|
||||
import { checkUixBootArtifact, uixBootCheck } from '../../../../scripts/uix-boot-check.ts';
|
||||
|
||||
const temp = mkdtempSync(join(tmpdir(), 'uix-boot-check-'));
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const MULTILANG_SCHEMA = 'src/uix/active-uix/test/boot-schema-multilang.ts';
|
||||
|
||||
/** Compile through the CLI, so the file under test is the one a site writes. */
|
||||
function compileTo(out: string, schema?: string): void {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
'tsx/esm',
|
||||
'scripts/generate-boot.ts',
|
||||
...(schema === undefined ? [] : ['--schema', schema]),
|
||||
'--out',
|
||||
out
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('uix-boot-check', () => {
|
||||
it('passes on the artifact this repo ships', async () => {
|
||||
const result = await checkUixBootArtifact();
|
||||
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.ok).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('watches every module the boot was compiled from, not just the entry', async () => {
|
||||
// Staleness almost never starts at the entry: it starts at a
|
||||
// dimension, or at the schema. A guard that watched `entry.ts` alone
|
||||
// would sit quiet through exactly the edits that break the artifact.
|
||||
const inputs = (await checkUixBootArtifact()).inputs.map((input) => resolve(input));
|
||||
|
||||
expect(inputs).toContain(resolve('src/uix/active-uix/boot/boot.ts'));
|
||||
expect(inputs).toContain(resolve('src/uix/active-uix/prefs-schema.ts'));
|
||||
expect(inputs).toContain(resolve('src/arts/prefs/dimensions/direction.ts'));
|
||||
}, 60_000);
|
||||
|
||||
it('fails when the artifact on disk was compiled from another schema', async () => {
|
||||
const out = join(temp, 'site-boot.js');
|
||||
compileTo(out, MULTILANG_SCHEMA);
|
||||
|
||||
expect((await checkUixBootArtifact({ schema: MULTILANG_SCHEMA, out })).ok).toBe(true);
|
||||
|
||||
// The same file, checked against the FRAMEWORK schema: a site that
|
||||
// recompiled with a flag and then dropped it, or a CI job wired to
|
||||
// the wrong module. Nothing about the file itself says which schema
|
||||
// built it — only a recompile does.
|
||||
const mismatched = await checkUixBootArtifact({ out });
|
||||
expect(mismatched.ok).toBe(false);
|
||||
expect(mismatched.reason).toContain('stale');
|
||||
}, 60_000);
|
||||
|
||||
it('sees a body edited under a hash that was left alone', async () => {
|
||||
// THE pair that makes a browser block the tag: the script moved, the
|
||||
// hash did not. It is the whole reason this guard compares the
|
||||
// artifact's TEXT instead of its hash — and the reason that decision
|
||||
// needs a test and not only the comment that argues it. Degrade the
|
||||
// comparison to the hash line and everything else here stays green.
|
||||
const out = join(temp, 'edited-boot.js');
|
||||
compileTo(out);
|
||||
const original = readFileSync(out, 'utf8');
|
||||
writeFileSync(out, original.replace('UIX_BOOT_SCRIPT = "', 'UIX_BOOT_SCRIPT = " '));
|
||||
const edited = readFileSync(out, 'utf8');
|
||||
|
||||
const hashLine = (source: string) =>
|
||||
source.split('\n').find((line) => line.startsWith('export const UIX_BOOT_CSP_HASH'));
|
||||
expect(hashLine(edited)).toBe(hashLine(original));
|
||||
expect(edited).not.toBe(original);
|
||||
|
||||
const result = await checkUixBootArtifact({ out });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('stale');
|
||||
}, 60_000);
|
||||
|
||||
it('fails when there is no artifact at all', async () => {
|
||||
const result = await checkUixBootArtifact({ out: join(temp, 'absent.js') });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('no compiled boot');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* The plugin half, inside a REAL Vite: `buildStart`, the build/serve split
|
||||
* and the dev watcher all run in Vite's own plugin container. The one
|
||||
* thing synthesised is the file-system event — the test writes a file and
|
||||
* then emits the `change` chokidar would, with real watching switched off
|
||||
* so no second, late event can race the assertions.
|
||||
*
|
||||
* Every dev assertion WAITS for a warning to appear. The failure mode of
|
||||
* this guard is a warning that never comes, so the red of each case is a
|
||||
* timeout, not a wrong value.
|
||||
*/
|
||||
describe('uixBootCheck — the Vite plugin', () => {
|
||||
const schemaWith = (value: string, imports = '') =>
|
||||
[
|
||||
"import { enumDimension } from '$prefs/dimensions/primitive';",
|
||||
imports,
|
||||
'export function bootPrefsSchema() {',
|
||||
`\treturn { contrast: enumDimension(['normal', 'more'], { default: ${value} }) };`,
|
||||
'}',
|
||||
''
|
||||
].join('\n');
|
||||
const HALF_TYPED = 'export function bootPrefsSchema() { return {{{ ;\n';
|
||||
const IMPORTS_EXTRA = "import { EXTRA } from './extra.ts';";
|
||||
|
||||
function site(name: string) {
|
||||
const root = join(temp, name);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const schema = join(root, 'prefs-schema.ts');
|
||||
const out = join(root, 'boot.js');
|
||||
return {
|
||||
root,
|
||||
schema,
|
||||
out,
|
||||
extra: join(root, 'extra.ts'),
|
||||
async compile() {
|
||||
writeFileSync(out, renderBootModule((await compileBoot({ schema })).script));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function recordingLogger(): { logger: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const logger = createLogger('silent');
|
||||
logger.warn = (message) => void warnings.push(message);
|
||||
logger.warnOnce = (message) => void warnings.push(message);
|
||||
return { logger, warnings };
|
||||
}
|
||||
|
||||
function devServer(where: ReturnType<typeof site>, logger: Logger): Promise<ViteDevServer> {
|
||||
return createServer({
|
||||
root: where.root,
|
||||
configFile: false,
|
||||
appType: 'custom',
|
||||
customLogger: logger,
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { middlewareMode: true, watch: { ignored: ['**/*'] } },
|
||||
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
|
||||
});
|
||||
}
|
||||
|
||||
const stale = (warnings: string[]) => warnings.filter((line) => line.includes('is stale'));
|
||||
const failed = (warnings: string[]) =>
|
||||
warnings.filter((line) => line.includes('could not compile the boot'));
|
||||
|
||||
function edit(server: ViteDevServer, file: string, text: string): void {
|
||||
writeFileSync(file, text);
|
||||
server.watcher.emit('change', file);
|
||||
}
|
||||
|
||||
it('fails a BUILD over a stale artifact', async () => {
|
||||
const where = site('build-stale');
|
||||
writeFileSync(where.schema, schemaWith("'normal'"));
|
||||
await where.compile();
|
||||
writeFileSync(where.schema, schemaWith("'more'"));
|
||||
const entry = join(where.root, 'main.js');
|
||||
writeFileSync(entry, 'export {};\n');
|
||||
|
||||
await expect(
|
||||
build({
|
||||
root: where.root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: { write: false, rollupOptions: { input: entry } },
|
||||
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
|
||||
})
|
||||
).rejects.toThrow('is stale');
|
||||
}, 60_000);
|
||||
|
||||
it('fails a BUILD over a boot that does not compile at all', async () => {
|
||||
// Dev turns a failed compile into a warning; a build has nothing to
|
||||
// recover to. Without this case, turning the build's failure into a
|
||||
// warning too left every other test here green.
|
||||
const where = site('build-broken');
|
||||
writeFileSync(where.schema, schemaWith("'normal'"));
|
||||
await where.compile();
|
||||
writeFileSync(where.schema, HALF_TYPED);
|
||||
const entry = join(where.root, 'main.js');
|
||||
writeFileSync(entry, 'export {};\n');
|
||||
|
||||
await expect(
|
||||
build({
|
||||
root: where.root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: { write: false, rollupOptions: { input: entry } },
|
||||
plugins: [uixBootCheck({ schema: where.schema, out: where.out })]
|
||||
})
|
||||
).rejects.toThrow('Build failed');
|
||||
}, 60_000);
|
||||
|
||||
it('keeps watching after a dev server STARTED on a schema that does not compile', async () => {
|
||||
// A half-typed module is the state a watched file is most often in.
|
||||
// Measured: a server that started there kept an EMPTY input set, so
|
||||
// every later edit was discarded as "not an input" and the guard said
|
||||
// nothing for the rest of the session.
|
||||
const where = site('dev-start-broken');
|
||||
writeFileSync(where.schema, schemaWith("'normal'"));
|
||||
await where.compile();
|
||||
writeFileSync(where.schema, HALF_TYPED);
|
||||
const { logger, warnings } = recordingLogger();
|
||||
|
||||
const server = await devServer(where, logger);
|
||||
try {
|
||||
expect(failed(warnings)).toHaveLength(1);
|
||||
|
||||
edit(server, where.schema, schemaWith("'more'"));
|
||||
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('watches a module the schema STARTS importing, from the next compile on', async () => {
|
||||
// Membership is decided against the inputs of the LAST compile, so
|
||||
// those inputs have to move with every compile. Frozen at start-up,
|
||||
// an edit to a module the schema imported later is never checked.
|
||||
const where = site('dev-new-input');
|
||||
writeFileSync(where.extra, "export const EXTRA = 'normal';\n");
|
||||
writeFileSync(where.schema, schemaWith("'normal'"));
|
||||
await where.compile();
|
||||
const { logger, warnings } = recordingLogger();
|
||||
|
||||
const server = await devServer(where, logger);
|
||||
try {
|
||||
expect(warnings).toHaveLength(0);
|
||||
|
||||
edit(server, where.schema, schemaWith('EXTRA', IMPORTS_EXTRA));
|
||||
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
|
||||
|
||||
await where.compile();
|
||||
edit(server, where.extra, "export const EXTRA = 'more';\n");
|
||||
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(2), { timeout: 20_000 });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('rechecks after a failed compile, whichever file the fix lands in', async () => {
|
||||
// A failed compile reports no inputs, so the last good set says
|
||||
// nothing about the file that broke it — here a module the schema
|
||||
// imports for the first time, broken as it is imported. The fix lands
|
||||
// in THAT module, which no successful compile ever read.
|
||||
const where = site('dev-broken-new-input');
|
||||
writeFileSync(where.schema, schemaWith("'normal'"));
|
||||
await where.compile();
|
||||
const { logger, warnings } = recordingLogger();
|
||||
|
||||
const server = await devServer(where, logger);
|
||||
try {
|
||||
writeFileSync(where.extra, 'export const EXTRA = ;\n');
|
||||
edit(server, where.schema, schemaWith('EXTRA', IMPORTS_EXTRA));
|
||||
await vi.waitFor(() => expect(failed(warnings)).toHaveLength(1), { timeout: 20_000 });
|
||||
|
||||
edit(server, where.extra, "export const EXTRA = 'more';\n");
|
||||
await vi.waitFor(() => expect(stale(warnings)).toHaveLength(1), { timeout: 20_000 });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The boot is a COMPILER a site runs, so its command line is a contract:
|
||||
* `--schema` decides which preference schema the boot resolves with and
|
||||
* `--out` decides where the site's artifact lands. This suite drives the
|
||||
* real command line — a child process, the same one the recipe in
|
||||
* `docs/theming/guide.md` tells a site to put in its `package.json`.
|
||||
*
|
||||
* The failure it is really about is the mute one. A flag that is accepted
|
||||
* and ignored, or a schema module that quietly drags the Svelte runtime
|
||||
* into a plain `<script>`, both end the same way: a page that looks fine
|
||||
* to whoever shipped it and is wrong for the user it was compiled for.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { BOOT_OUTPUT } from '../../../../scripts/generate-boot.ts';
|
||||
|
||||
const temp = mkdtempSync(join(tmpdir(), 'uix-boot-cli-'));
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface CliResult {
|
||||
readonly status: number;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
function runCompiler(args: readonly string[]): CliResult {
|
||||
try {
|
||||
execFileSync(process.execPath, ['--import', 'tsx/esm', 'scripts/generate-boot.ts', ...args], {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
return { status: 0, stderr: '' };
|
||||
} catch (error) {
|
||||
const failure = error as { status?: number; stderr?: Buffer };
|
||||
return { status: failure.status ?? -1, stderr: String(failure.stderr ?? '') };
|
||||
}
|
||||
}
|
||||
|
||||
describe('the boot compiler command line', () => {
|
||||
it('writes the framework artifact, byte for byte, when no schema is named', () => {
|
||||
const out = join(temp, 'default-boot.js');
|
||||
|
||||
expect(runCompiler(['--out', out]).status).toBe(0);
|
||||
// `--out` moves the file and changes NOTHING else: the checked-in
|
||||
// artifact is reproducible from the command line.
|
||||
expect(readFileSync(out, 'utf8')).toBe(readFileSync(BOOT_OUTPUT, 'utf8'));
|
||||
});
|
||||
|
||||
it('compiles a DIFFERENT boot for a site that names its own schema', () => {
|
||||
const base = join(temp, 'base-boot.js');
|
||||
const site = join(temp, 'site-boot.js');
|
||||
|
||||
expect(runCompiler(['--out', base]).status).toBe(0);
|
||||
expect(
|
||||
runCompiler(['--schema', 'src/uix/active-uix/test/boot-schema-multilang.ts', '--out', site])
|
||||
.status
|
||||
).toBe(0);
|
||||
|
||||
// Same entry, same output path shape, one flag of difference: the
|
||||
// only thing that can have moved is the schema the boot resolves
|
||||
// with. A `--schema` that was accepted and ignored dies here.
|
||||
expect(readFileSync(site, 'utf8')).not.toBe(readFileSync(base, 'utf8'));
|
||||
});
|
||||
|
||||
it('refuses an argument it does not understand instead of ignoring it', () => {
|
||||
// A compiler that ignores `--schemaa` hands the site the DEFAULT boot
|
||||
// while the site believes it compiled its own — the silent wrong
|
||||
// answer this axis exists to remove.
|
||||
const result = runCompiler(['--schemaa', 'src/uix/active-uix/test/boot-schema-axes.ts']);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('unknown argument');
|
||||
expect(result.stderr).toContain('usage: generate-boot');
|
||||
// And it says so as a MESSAGE. Node's default handler buries the
|
||||
// usage block under a source banner and six frames of framework
|
||||
// stack, which reads like a crash in UIX rather than a typo.
|
||||
expect(result.stderr).not.toContain('Node.js v');
|
||||
expect(result.stderr).not.toContain('at parseGenerateBootArgs');
|
||||
});
|
||||
|
||||
it('refuses an artifact that does not RUN, however well-formed its text is', () => {
|
||||
// The text guards are static and a dead boot passes all of them.
|
||||
// Measured before this check existed: both of these compiled with
|
||||
// exit 0, a valid CSP hash and a page that stamped nothing — and
|
||||
// nothing anywhere said so, because the tag swallows its own
|
||||
// failures by design. Worse than the defect `--schema` closes: the
|
||||
// wrong boot at least painted, a dead one brings the flash back
|
||||
// whole.
|
||||
const dead = {
|
||||
'throws-schema.ts': [
|
||||
'export function bootPrefsSchema(defaultLocale: string) {',
|
||||
'\tthrow new Error(`no schema for ${defaultLocale}`);',
|
||||
'}',
|
||||
''
|
||||
],
|
||||
'object-schema.ts': ["export const bootPrefsSchema = { language: 'not a function' };", '']
|
||||
};
|
||||
|
||||
for (const [name, lines] of Object.entries(dead)) {
|
||||
const schema = join(temp, name);
|
||||
writeFileSync(schema, lines.join('\n'));
|
||||
const out = join(temp, `${name}.js`);
|
||||
|
||||
const result = runCompiler(['--schema', schema, '--out', out]);
|
||||
|
||||
expect(result.status, name).not.toBe(0);
|
||||
expect(result.stderr, name).toContain('stamped NOTHING');
|
||||
// And it writes nothing: a refused compile must not leave a file
|
||||
// a build could pick up.
|
||||
expect(existsSync(out), name).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a declared dimension with no string to stamp', () => {
|
||||
// The run above used to COUNT attributes, and a browser turns any
|
||||
// value into text: measured, a schema whose dimensions resolved to
|
||||
// nothing compiled with exit 0 and put `lang="undefined"` on `<html>`
|
||||
// for the whole session. A redefined axis without a default is the
|
||||
// same mistake on an axis the boot always stamps.
|
||||
const schema = join(temp, 'no-default-schema.ts');
|
||||
writeFileSync(
|
||||
schema,
|
||||
[
|
||||
"import { enumDimension } from '$prefs/dimensions/primitive';",
|
||||
'',
|
||||
'export function bootPrefsSchema() {',
|
||||
"\treturn { density: enumDimension(['compact', 'comfortable'], {}) };",
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
const out = join(temp, 'no-default-boot.js');
|
||||
|
||||
const result = runCompiler(['--schema', schema, '--out', out]);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('`data-density` = undefined');
|
||||
expect(result.stderr).toContain('must be a string');
|
||||
expect(existsSync(out)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a syntax error in the schema instead of dying on an empty metafile', () => {
|
||||
// `metafile: true` makes esbuild's own client run `JSON.parse('')` on
|
||||
// a FAILED build, from inside a socket handler — an uncaught
|
||||
// exception, not a rejection, so no `catch` upstream can hold it.
|
||||
// Measured: that is what killed a site's dev server over one typo.
|
||||
// The input list comes from an `onLoad` plugin instead, and a failed
|
||||
// compile is an ordinary rejection again.
|
||||
const schema = join(temp, 'broken-schema.ts');
|
||||
writeFileSync(schema, 'export function bootPrefsSchema(l) { return {{{ ;\n');
|
||||
|
||||
const result = runCompiler(['--schema', schema, '--out', join(temp, 'broken-boot.js')]);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Expected identifier');
|
||||
expect(result.stderr).not.toContain('Unexpected end of JSON input');
|
||||
});
|
||||
|
||||
it('names the directory it resolved a missing `--schema` against', () => {
|
||||
// The recipe's own trap: `npm run generate:boot` pins the working
|
||||
// directory to the framework, so a path relative to the SITE lands
|
||||
// nowhere. esbuild's answer points at `boot.ts` and reads like a
|
||||
// framework bug; this one points at the site's mistake.
|
||||
const result = runCompiler(['--schema', 'src/prefs-schema.ts']);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('no schema module at');
|
||||
expect(result.stderr).toContain('resolved against the current directory');
|
||||
});
|
||||
|
||||
it('refuses a flag left without a value', () => {
|
||||
const result = runCompiler(['--schema']);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('needs a value');
|
||||
});
|
||||
|
||||
it('fails the compile when a schema module reaches for the `$prefs` barrel', () => {
|
||||
// Measured: importing `standardPrefsDimensions` from the barrel pulls
|
||||
// `$state(` into the bundle (and takes it from 15 KB to 56 KB). In a
|
||||
// plain `<script>` that throws on line one, before a single attribute
|
||||
// is stamped, and the tag swallows it by design — so the compiler is
|
||||
// the only place it can be caught.
|
||||
const schema = join(temp, 'barrel-schema.ts');
|
||||
writeFileSync(
|
||||
schema,
|
||||
[
|
||||
"import { standardPrefsDimensions } from '$prefs';",
|
||||
'',
|
||||
'export function bootPrefsSchema(defaultLocale: string) {',
|
||||
'\treturn standardPrefsDimensions({',
|
||||
'\t\tlanguages: [defaultLocale],',
|
||||
'\t\tlocales: [defaultLocale],',
|
||||
"\t\tcurrencies: ['USD']",
|
||||
'\t});',
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = runCompiler(['--schema', schema, '--out', join(temp, 'barrel-boot.js')]);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
// The message is for whoever wrote the schema, not for the framework.
|
||||
expect(result.stderr).toContain('$state(');
|
||||
expect(result.stderr).toContain('`$prefs` barrel');
|
||||
expect(result.stderr).toContain(schema);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* THE SEAM: the one module `scripts/generate-boot.ts` swaps.
|
||||
*
|
||||
* `boot.ts` imports `bootPrefsSchema` from HERE and from nowhere else,
|
||||
* with a fixed, relative specifier. Nothing is dynamic — the boot's
|
||||
* purity (no Svelte, no `$app/*`, no runes, one `<script>` body whose
|
||||
* bytes are a CSP hash) is load-bearing and a runtime lookup would cost
|
||||
* all of it. The substitution happens at COMPILE time: with no flags the
|
||||
* generator resolves this file, which answers with the framework's
|
||||
* default (`./default-schema.ts`); with `--schema <module>` it resolves
|
||||
* the site's module instead, and the boot is compiled around the site's
|
||||
* catalogues.
|
||||
*
|
||||
* The reason a site ever needs that: the schema decides which languages,
|
||||
* which currencies and which axes exist. A boot built around the
|
||||
* framework default knows ONE language, so on a multi-language site it
|
||||
* stamps `dir="ltr"` for an Arabic user and the page paints left to right
|
||||
* until hydration flips it. Compiling the boot with the site's schema is
|
||||
* what closes that.
|
||||
*
|
||||
* A site's module must export exactly this shape, and must stay as pure
|
||||
* as this one: deep imports (`$prefs/standard`, `$prefs/dimensions/*`),
|
||||
* never the `$prefs` barrel, which re-exports a `.svelte.ts` and would
|
||||
* put `$state(` in a plain `<script>`. The generator fails the build with
|
||||
* that message when it happens.
|
||||
*/
|
||||
export type { BootPrefsSchema } from './default-schema.ts';
|
||||
export { bootPrefsSchema } from './default-schema.ts';
|
||||
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* What the seam (`./boot-schema.ts`) answers when a site compiles the
|
||||
* boot with no `--schema`: UIX's own default schema, the one
|
||||
* `createActiveUix` falls back to when the app passes none. The two
|
||||
* consumers agree by construction because it is literally the same
|
||||
* function.
|
||||
*/
|
||||
import type { SupportedLocale } from '$libs/langs';
|
||||
import type { PrefsSchema } from '$libs/prefs';
|
||||
|
||||
import { createDefaultUixPrefsSchema } from '../prefs-schema.ts';
|
||||
|
||||
/**
|
||||
* The shape a site's `--schema` module must export: the app's preference
|
||||
* schema, built around the locale the app composed UIX with. It is the
|
||||
* SAME object the app passes to `createActiveUix({ prefs: { schema } })`
|
||||
* — that is the whole point, and `composeUixPrefsSchema` puts the four
|
||||
* visual axes under it on both sides.
|
||||
*/
|
||||
export type BootPrefsSchema = (defaultLocale: SupportedLocale) => PrefsSchema;
|
||||
|
||||
export const bootPrefsSchema: BootPrefsSchema = createDefaultUixPrefsSchema;
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A site that REDEFINES two of the visual axes: a narrower `density`
|
||||
* catalogue and a narrower `scaling` one, both with a default of its own.
|
||||
*
|
||||
* The axes are the case the merge order decides. They travel with the
|
||||
* root, so the app's redefinition has to sit ON TOP of
|
||||
* `uixVisualPrefsDimensions()` — invert the merge and the site's
|
||||
* defaults vanish behind the framework's, before paint and after
|
||||
* hydration alike.
|
||||
*
|
||||
* Deep imports, same rule as its sibling: this module is compiled into a
|
||||
* plain `<script>` body.
|
||||
*/
|
||||
import { enumDimension } from '$prefs/dimensions/primitive';
|
||||
import type { SupportedLocale } from '$libs/langs';
|
||||
import type { PrefsSchema } from '$libs/prefs';
|
||||
|
||||
import { createDefaultUixPrefsSchema } from '../prefs-schema.ts';
|
||||
|
||||
export function bootPrefsSchema(defaultLocale: SupportedLocale): PrefsSchema {
|
||||
return {
|
||||
...createDefaultUixPrefsSchema(defaultLocale),
|
||||
density: enumDimension(['compact', 'comfortable'] as const, { default: 'compact' }),
|
||||
scaling: enumDimension(['100', '110'] as const, { default: '110' })
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* A multi-language site's preference schema — the fixture that proves
|
||||
* the boot resolves with the SITE's catalogues.
|
||||
*
|
||||
* It is what a real app composes: the standard preset around its own
|
||||
* languages, and NOT the four visual axes — those travel with the root
|
||||
* (`composeUixPrefsSchema` puts them underneath, on both sides of the
|
||||
* hydration line).
|
||||
*
|
||||
* Deep imports on purpose. This module is compiled INTO a `<script>`
|
||||
* body by `scripts/generate-boot.ts --schema`, where nothing runs the
|
||||
* Svelte compiler: the `$prefs` barrel re-exports a `.svelte.ts` and
|
||||
* would put `$state(` in the tag. Every site schema module lives under
|
||||
* the same rule, and the generator fails the build when it is broken.
|
||||
*/
|
||||
import { standardPrefsDimensions } from '$prefs/standard';
|
||||
import type { SupportedLocale } from '$libs/langs';
|
||||
import type { PrefsSchema } from '$libs/prefs';
|
||||
|
||||
export function bootPrefsSchema(defaultLocale: SupportedLocale): PrefsSchema {
|
||||
return standardPrefsDimensions({
|
||||
languages: [defaultLocale, 'ar', 'en'],
|
||||
locales: [defaultLocale, 'ar', 'en'],
|
||||
currencies: ['EUR', 'USD'],
|
||||
defaults: { language: defaultLocale, locale: defaultLocale, currency: 'EUR' }
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* A site that declares only the preferences it uses: `motion`, and one
|
||||
* dimension of its own. No `language` and no `direction` — `lang` and
|
||||
* `dir` are the server's (i18n by routing) — and no `sound` or `haptic`.
|
||||
*
|
||||
* The runtime tolerates that by design: the prefs projection reads one
|
||||
* slot per dimension and writes nothing for a dimension the schema does
|
||||
* not declare. The boot has to answer the same way — an attribute the
|
||||
* runtime leaves alone is an attribute the boot leaves alone, never one
|
||||
* stamped with the text `undefined`.
|
||||
*
|
||||
* Deep imports, same rule as its siblings: this module is compiled into a
|
||||
* plain `<script>` body.
|
||||
*/
|
||||
import { motionDimension } from '$prefs/dimensions/motion';
|
||||
import { enumDimension } from '$prefs/dimensions/primitive';
|
||||
import type { PrefsSchema } from '$libs/prefs';
|
||||
|
||||
export function bootPrefsSchema(): PrefsSchema {
|
||||
return {
|
||||
motion: motionDimension(),
|
||||
contrast: enumDimension(['normal', 'more'] as const, { default: 'normal' })
|
||||
};
|
||||
}
|
||||
Loading…
Reference in new issue