|
| 1 | +/** |
| 2 | + * Quick task 260623-kks — Vue emitter null-default hardening. |
| 3 | + * |
| 4 | + * Root cause: an untyped optional prop authored `{ type: null, default: null }` |
| 5 | + * lowers to `{ kind: 'identifier', name: 'unknown' }`. `renderPropField` widens |
| 6 | + * it to `unknown | null`; TS collapses `unknown | null` → `unknown`; Vue's |
| 7 | + * `withDefaults` `InferDefault<P, unknown>` resolves the default-slot type to |
| 8 | + * `((props) => {}) | undefined`, which REJECTS the emitted `null` literal default |
| 9 | + * inside `withDefaults(..., { obj: null })` → TS2322. |
| 10 | + * |
| 11 | + * RED-first oracle: this file is written and run BEFORE the emitter fix. Both |
| 12 | + * assertions must FAIL on the current emitter, then PASS after the surgical |
| 13 | + * `renderPropField` change (gate `needsNull && baseType === 'unknown'` → |
| 14 | + * non-collapsing base). |
| 15 | + * |
| 16 | + * - Assertion A (string shape): the emitted `obj?:` field is NOT the collapsing |
| 17 | + * `unknown | null` — it carries a non-collapsing base that keeps both union |
| 18 | + * branches. |
| 19 | + * - Assertion B (strict vue-tsc oracle, load-bearing): write the emitted SFC to |
| 20 | + * a tmpdir, run `vue-tsc --noEmit` under a STRICT tsconfig, assert ZERO TS2322. |
| 21 | + * The Function prop (`fn`, `{ type: Function, default: null }`) must ALSO |
| 22 | + * produce 0 TS2322 — it already emits `((...args: any[]) => any) | null` and |
| 23 | + * must stay byte-identical (non-regression leg). |
| 24 | + */ |
| 25 | +import { describe, it, expect } from 'vitest'; |
| 26 | +import { execFileSync } from 'node:child_process'; |
| 27 | +import { |
| 28 | + mkdtempSync, |
| 29 | + rmSync, |
| 30 | + writeFileSync, |
| 31 | + symlinkSync, |
| 32 | + existsSync, |
| 33 | +} from 'node:fs'; |
| 34 | +import { join, resolve, dirname } from 'node:path'; |
| 35 | +import { tmpdir } from 'node:os'; |
| 36 | +import { fileURLToPath } from 'node:url'; |
| 37 | +import { parse } from '../../../../../core/src/parse.js'; |
| 38 | +import { lowerToIR } from '../../../../../core/src/ir/lower.js'; |
| 39 | +import { createDefaultRegistry } from '../../../../../core/src/modifiers/registerBuiltins.js'; |
| 40 | +import { emitVue } from '../../emitVue.js'; |
| 41 | + |
| 42 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 43 | +const ROOT = resolve(HERE, '../../../../../..'); |
| 44 | + |
| 45 | +// vue-tsc is NOT a direct devDep of @rozie/target-vue (W2). Resolve it from the |
| 46 | +// tests/vue-typecheck harness package, which depends on it and has vue resolvable. |
| 47 | +const VUE_TYPECHECK_DIR = resolve(ROOT, 'tests/vue-typecheck'); |
| 48 | +const VUE_TSC_BIN = resolve(VUE_TYPECHECK_DIR, 'node_modules/.bin/vue-tsc'); |
| 49 | + |
| 50 | +function compile(rozieSrc: string): string { |
| 51 | + const { ast } = parse(rozieSrc, { filename: 'Test.rozie' }); |
| 52 | + if (!ast) throw new Error('parse() returned null'); |
| 53 | + const { ir } = lowerToIR(ast, { modifierRegistry: createDefaultRegistry() }); |
| 54 | + if (!ir) throw new Error('lowerToIR() returned null'); |
| 55 | + const result = emitVue(ir, { filename: 'Test.rozie', source: rozieSrc }); |
| 56 | + return result.code; |
| 57 | +} |
| 58 | + |
| 59 | +const STRICT_TSCONFIG = JSON.stringify({ |
| 60 | + compilerOptions: { |
| 61 | + target: 'ES2022', |
| 62 | + module: 'ESNext', |
| 63 | + moduleResolution: 'bundler', |
| 64 | + jsx: 'preserve', |
| 65 | + strict: true, |
| 66 | + noImplicitAny: true, |
| 67 | + strictNullChecks: true, |
| 68 | + skipLibCheck: true, |
| 69 | + isolatedModules: true, |
| 70 | + esModuleInterop: true, |
| 71 | + allowSyntheticDefaultImports: true, |
| 72 | + noEmit: true, |
| 73 | + lib: ['ES2022', 'DOM'], |
| 74 | + types: ['vue'], |
| 75 | + }, |
| 76 | + include: ['**/*.vue'], |
| 77 | +}); |
| 78 | + |
| 79 | +/** |
| 80 | + * Write the emitted SFC into a tmpdir alongside a STRICT tsconfig, symlink the |
| 81 | + * vue-typecheck harness's node_modules so `vue` + its types resolve, run |
| 82 | + * `vue-tsc --noEmit`, and return the captured stdout+stderr (vue-tsc writes |
| 83 | + * diagnostics to stdout on non-zero, throwing via execFileSync). |
| 84 | + */ |
| 85 | +function strictVueTsc(sfc: string, name = 'Probe.vue'): string { |
| 86 | + const tmpDir = mkdtempSync(join(tmpdir(), 'rozie-nulldefault-')); |
| 87 | + try { |
| 88 | + writeFileSync(join(tmpDir, name), sfc); |
| 89 | + writeFileSync(join(tmpDir, 'tsconfig.json'), STRICT_TSCONFIG); |
| 90 | + symlinkSync( |
| 91 | + join(VUE_TYPECHECK_DIR, 'node_modules'), |
| 92 | + join(tmpDir, 'node_modules'), |
| 93 | + 'dir', |
| 94 | + ); |
| 95 | + try { |
| 96 | + execFileSync(VUE_TSC_BIN, ['--noEmit', '-p', 'tsconfig.json'], { |
| 97 | + cwd: tmpDir, |
| 98 | + stdio: 'pipe', |
| 99 | + }); |
| 100 | + return ''; // clean |
| 101 | + } catch (err) { |
| 102 | + const stdout = (err as { stdout?: Buffer }).stdout?.toString() ?? ''; |
| 103 | + const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? ''; |
| 104 | + return stdout + '\n' + stderr; |
| 105 | + } |
| 106 | + } finally { |
| 107 | + rmSync(tmpDir, { recursive: true, force: true }); |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +function countTS2322(diag: string): number { |
| 112 | + return (diag.match(/TS2322/g) ?? []).length; |
| 113 | +} |
| 114 | + |
| 115 | +describe('Vue emitter — null-default untyped prop (260623-kks)', () => { |
| 116 | + it('precondition: vue-tsc binary is resolvable from the harness', () => { |
| 117 | + expect(existsSync(VUE_TSC_BIN)).toBe(true); |
| 118 | + }); |
| 119 | + |
| 120 | + const SRC = `<rozie name="Probe"> |
| 121 | +<props> |
| 122 | +{ |
| 123 | + obj: { type: null, default: null }, |
| 124 | + fn: { type: Function, default: null } |
| 125 | +} |
| 126 | +</props> |
| 127 | +<template> |
| 128 | + <div></div> |
| 129 | +</template> |
| 130 | +</rozie>`; |
| 131 | + |
| 132 | + it('Assertion A — obj field is NOT the collapsing `unknown | null`', () => { |
| 133 | + const code = compile(SRC); |
| 134 | + // The emitted defineProps field for `obj` must not collapse to unknown. |
| 135 | + expect(code).not.toContain('obj?: unknown | null'); |
| 136 | + // It must keep a non-collapsing base with both union branches. |
| 137 | + expect(code).toMatch(/obj\?: [^;}]*\| null/); |
| 138 | + }); |
| 139 | + |
| 140 | + it('Assertion B — strict vue-tsc of the emitted SFC has ZERO TS2322 (obj + fn)', () => { |
| 141 | + const code = compile(SRC); |
| 142 | + const diag = strictVueTsc(code); |
| 143 | + const n = countTS2322(diag); |
| 144 | + if (n !== 0) { |
| 145 | + // Surface the captured diagnostics on failure for fast triage. |
| 146 | + throw new Error( |
| 147 | + `Expected 0 TS2322 in strict vue-tsc of the emitted SFC, got ${n}:\n${diag}`, |
| 148 | + ); |
| 149 | + } |
| 150 | + expect(n).toBe(0); |
| 151 | + }); |
| 152 | +}); |
0 commit comments