import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { compileScript, compileTemplate, parse } from "vue/compiler-sfc"; import ts from "typescript"; const root = fileURLToPath(new URL("../src/", import.meta.url)); const nativeControls = new Set([ "switch", "slider", "picker", "picker-view", "checkbox", "checkbox-group", "radio", "radio-group", ]); function vueFiles(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); return entry.isDirectory() ? vueFiles(path) : path.endsWith(".vue") ? [path] : []; }); } function compile(source, filename) { return compileTemplate({ source, filename, id: "native-component-regression", compilerOptions: { isNativeTag: (tag) => nativeControls.has(tag) || ["view", "text", "input", "textarea"].includes(tag), }, }); } function elements(node) { return [node, ...(node.children ?? []).flatMap(elements)].filter((item) => item.type === 1); } test("the check detects unsupported v-model on native switches", () => { const result = compile('', "invalid-switch.vue"); assert.ok(result.errors.some((error) => String(error.message).includes("v-model can only be used"))); }); test("all mobile templates compile without unsupported native v-model", () => { const files = vueFiles(root); assert.ok(files.length > 0); for (const filename of files) { const { descriptor, errors } = parse(readFileSync(filename, "utf8"), { filename }); assert.deepEqual(errors, [], relative(root, filename)); if (!descriptor.template) continue; const result = compile(descriptor.template.content, filename); assert.deepEqual(result.errors, [], relative(root, filename)); } }); for (const [page, model] of [["settings", "privacy"], ["notification-settings", "form"]]) { test(`${page} switch change updates both checked and unchecked form values`, () => { const filename = join(root, "pages", page, "index.vue"); const { descriptor } = parse(readFileSync(filename, "utf8"), { filename }); const switches = elements(descriptor.template.ast).filter((node) => node.tag === "switch"); assert.equal(switches.length, 1); const props = switches[0].props; assert.ok(!props.some((prop) => prop.name === "model")); const checked = props.find((prop) => prop.name === "bind" && prop.arg?.content === "checked"); const change = props.find((prop) => prop.name === "on" && prop.arg?.content === "change"); assert.ok(checked?.exp?.content); assert.ok(change?.exp?.content); const state = { enabled: false, otherSetting: true }; // Exercise the actual event handler and bindings without mounting a native // runtime or calling the API; only the component's own handler is evaluated. const script = compileScript(descriptor, { id: `test-${page}` }); const handler = script.scriptSetupAst.find((node) => node.type === "FunctionDeclaration" && node.id.name === "setSwitch"); assert.ok(handler); const handlerSource = descriptor.scriptSetup.content.slice(handler.start, handler.end); const handlerJS = ts.transpileModule(handlerSource, { compilerOptions: { target: ts.ScriptTarget.ES2020 }, }).outputText; const setSwitch = new Function(model, `${handlerJS}; return setSwitch;`)({ value: state }); const update = new Function("setSwitch", "item", "$event", change.exp.content); const readChecked = new Function(model, "item", `return ${checked.exp.content}`); for (const value of [true, false, true]) { update(setSwitch, ["enabled"], { detail: { value } }); assert.equal(state.enabled, value); assert.equal(readChecked(state, ["enabled"]), value); assert.equal(state.otherSetting, true); } }); }