Files
2026-09-03 08:38:17 +08:00

85 lines
3.9 KiB
JavaScript

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('<switch v-model="enabled" />', "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);
}
});
}