mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Live mode scaffolds each Svelte variant with a props script that annotated the
declaration:
/** @type {{ title: string; }} */
let { title } = $props();
A JSDoc `@type` written directly before a value is also JSDoc's cast syntax,
and esrap 2.3.3, the printer Svelte emits JS through, moves that annotation
onto the template's own declaration:
var /** @type {{ title: string; }} */ (h1) = root();
`var (h1) = ...` does not parse. The .svelte source is valid, the compile
succeeds, and the failure lands in the browser's dynamic import as "Unexpected
token '('": the variant never mounts and the session shows nothing. `@typedef`
carries the same shape without being a cast, so both builders emit that.
This is not test-only. Every Svelte variant we generate carried the construct,
so live mode was broken for any user whose install resolved esrap 2.3.3.
Svelte declares `esrap: ^2.2.12`, so a fresh install takes it; this repo's
lockfile pins 2.3.0, which is why unit tests stayed green while the fixture,
which installs into a temp dir, did not.
Two reasons the existing pre-publish guard could not have caught it, now
recorded next to it:
- `compileCheckVariants` compiles with `generate: false`, so there is no
emitted JS to inspect.
- `loadSvelteCompiler` resolves the compiler through createRequire, which
Svelte's export map routes to a prebuilt CJS build. A dev server imports
`src/compiler`, and only that path runs the app's installed printer. The
guard was checking a different compiler than the browser runs.
The new suite therefore imports the compiler as ESM and asserts the emitted
JavaScript parses, rather than pinning the comment style: a future printer that
mangles some other construct fails it too. The first draft used createRequire
and reported green against the exact input that breaks in a browser, which is
the mistake worth not repeating.
Verified against svelte 5.56.9 with esrap 2.3.3. Full live-e2e sweep green,
26 fixtures.
Written with AI assistance (Claude Code).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
6.8 KiB
JavaScript
164 lines
6.8 KiB
JavaScript
/**
|
|
* The variant components live mode scaffolds must survive the app's own Svelte
|
|
* compiler AND parse as JavaScript afterwards. Run with:
|
|
* node --test tests/live-svelte-props-script.test.mjs
|
|
*
|
|
* `compileCheckVariants` already compiles each variant with `generate: false`,
|
|
* which proves the .svelte source parses. It cannot prove the emitted module
|
|
* parses, and that is the gap this suite covers. In issue #580, esrap 2.3.3
|
|
* (Svelte's JS printer, pulled in by `esrap: ^2.2.12`) printed a
|
|
* `/** @type {...} *\/` written directly before a destructuring declaration as
|
|
* JSDoc cast syntax on the template's own declaration,
|
|
*
|
|
* var /** @type {{ title: string; }} *\/ (h1) = root();
|
|
*
|
|
* which compiles without complaint and then dies in the browser's dynamic
|
|
* import with "Unexpected token '('". Nothing rendered, and the failure
|
|
* surfaced two layers away from the comment that caused it.
|
|
*
|
|
* Two things follow, and both shape this file:
|
|
*
|
|
* 1. The assertion is on the emitted JavaScript, never on the comment style. A
|
|
* future printer that mangles some other construct fails here too, which a
|
|
* test pinned to `@typedef` would not.
|
|
* 2. The compiler is imported as ESM, because that is what the dev server
|
|
* resolves. Svelte's export map sends `require` to a prebuilt CJS compiler
|
|
* and `import` to `src/compiler`, and only the latter goes through the
|
|
* installed esrap. Reaching for `createRequire` here (as `loadSvelteCompiler`
|
|
* does) compiles with a different printer than the browser ever sees, and
|
|
* the guard passes while the product is broken. That is not hypothetical:
|
|
* the first draft of this suite did exactly that and reported green.
|
|
*/
|
|
|
|
import { describe, it, before, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { buildPropsScriptV2 } from '../skill/scripts/live/svelte-ast.mjs';
|
|
import { scaffoldSvelteComponentSession } from '../skill/scripts/live/svelte-component.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
let compile;
|
|
let acornParse;
|
|
let svelteVersion;
|
|
let printerVersion;
|
|
|
|
before(async () => {
|
|
// ESM import, not createRequire: see note 2 in the header. This is the build
|
|
// a Vite dev server loads, and the only one that uses the installed esrap.
|
|
const compiler = await import('svelte/compiler');
|
|
compile = compiler.compile;
|
|
svelteVersion = compiler.VERSION;
|
|
acornParse = require('acorn').parse;
|
|
try {
|
|
printerVersion = require('esrap/package.json').version;
|
|
} catch {
|
|
printerVersion = 'unknown';
|
|
}
|
|
});
|
|
|
|
function assertEmittedJsParses(svelteSource, label) {
|
|
const { js } = compile(svelteSource, {
|
|
generate: 'client',
|
|
// dev:true is what a dev server uses, and it is the mode that carries the
|
|
// defect: the extra location metadata is where the stray annotation lands.
|
|
dev: true,
|
|
filename: 'v1.svelte',
|
|
});
|
|
try {
|
|
acornParse(js.code, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
} catch (err) {
|
|
const line = js.code.split('\n')[(err.loc?.line ?? 1) - 1] || '';
|
|
assert.fail(
|
|
`${label}: svelte ${svelteVersion} (printer esrap ${printerVersion}) emitted JavaScript that does not parse.\n`
|
|
+ ` ${err.message}\n`
|
|
+ ` offending line: ${line.trim()}\n`
|
|
+ ` The browser reports this as a mount failure, not as a compile error, `
|
|
+ `because the .svelte source is valid and only the emitted module is not.\n`
|
|
+ ` source:\n${svelteSource}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('scaffolded props scripts emit parseable JavaScript', () => {
|
|
// The empty contract is the shape the CI fixture hit: a picked element with
|
|
// no dynamic values at all.
|
|
const CONTRACTS = {
|
|
'no props': [],
|
|
'one text prop': [{ prop: 'title', expr: 'title', kind: 'text' }],
|
|
'every prop kind': [
|
|
{ prop: 'title', expr: 'title', kind: 'text' },
|
|
{ prop: 'body', expr: 'post.body', kind: 'raw' },
|
|
{ prop: 'isOpen', expr: 'open', kind: 'condition' },
|
|
{ prop: 'items', expr: 'stages', kind: 'collection' },
|
|
{ prop: 'onSelect', expr: 'select', kind: 'handler' },
|
|
],
|
|
};
|
|
|
|
for (const [label, contract] of Object.entries(CONTRACTS)) {
|
|
it(`buildPropsScriptV2: ${label}`, () => {
|
|
const source = `${buildPropsScriptV2(contract)}\n<h1 class="hero-title">Fixture</h1>\n`;
|
|
assertEmittedJsParses(source, `buildPropsScriptV2 (${label})`);
|
|
});
|
|
}
|
|
|
|
// Deliberately NOT asserted here: that the old `@type` shape still breaks.
|
|
// Whether it breaks depends on the installed printer (esrap 2.3.3 yes, 2.3.2
|
|
// no), and this repo's lockfile carries a good one while a fresh fixture
|
|
// install pulls the bad one. An assertion that upstream is still broken would
|
|
// fail in this repo and pass in CI, which is the wrong way round for a guard.
|
|
// The live-e2e suite installs fresh and is where the real printer gets
|
|
// exercised; these cases pin what we emit, on whatever printer is present.
|
|
});
|
|
|
|
describe('the real scaffolder writes variants that parse', () => {
|
|
let scratch;
|
|
|
|
beforeEach(() => {
|
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-svelte-scaffold-'));
|
|
// The scaffolder resolves the compiler from the app root, so the staged app
|
|
// needs a package.json and a reachable svelte. Symlinking the repo's
|
|
// node_modules is what the static fixture sweep already does.
|
|
fs.writeFileSync(path.join(scratch, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }));
|
|
fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), path.join(scratch, 'node_modules'), 'dir');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
});
|
|
|
|
const CASES = {
|
|
'static markup (the #580 shape)': ['<h1 class="hero-title">Vite 8 + SvelteKit Fixture</h1>'],
|
|
'markup with a free expression': ['<h1 class="hero-title">{headline}</h1>'],
|
|
};
|
|
|
|
for (const [label, originalLines] of Object.entries(CASES)) {
|
|
it(label, () => {
|
|
const result = scaffoldSvelteComponentSession({
|
|
id: 'testsession',
|
|
count: 3,
|
|
sourceFile: 'src/routes/+page.svelte',
|
|
sourceStartLine: 1,
|
|
sourceEndLine: originalLines.length,
|
|
originalLines,
|
|
cwd: scratch,
|
|
});
|
|
assert.equal(result.fallback, undefined, `scaffold fell back: ${result.reason}`);
|
|
|
|
const dir = path.join(scratch, 'node_modules', '.impeccable-live', 'testsession');
|
|
const variants = fs.readdirSync(dir).filter((name) => /^v\d+\.svelte$/.test(name));
|
|
assert.ok(variants.length > 0, 'scaffolder wrote no variant files');
|
|
|
|
for (const name of variants) {
|
|
assertEmittedJsParses(fs.readFileSync(path.join(dir, name), 'utf-8'), `${label} / ${name}`);
|
|
}
|
|
});
|
|
}
|
|
});
|