import { parse, type AST } from "svelte/compiler";
// the svelte ast is estree with typescript nodes mixed in, so nodes are read by their type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Node = { type: string } & Record<string, any>;
type Attribute = AST.RegularElement["attributes"][number] | AST.Component["attributes"][number];
type Element = AST.RegularElement | AST.Component;
type Found = { id: string; source: string };
type Insert = { at: number; text: string };
type Seen = { line: number; id: string | null; source: string | null };
type Options = { component?: string; attribute?: string };
// data-testid rides on the primitive's rest props, testid is a prop for the ones that spread none
export const components: Record<string, "data-testid" | "testid"> = {
ActorInput: "data-testid",
Button: "data-testid",
ButtonCard: "data-testid",
Checkbox: "data-testid",
DropdownItem: "data-testid",
Input: "data-testid",
Link: "data-testid",
Radio: "data-testid",
Segment: "data-testid",
Textarea: "data-testid",
Toggle: "data-testid",
Combobox: "testid",
ConfirmModal: "testid",
Dropdown: "testid",
MarkdownEditor: "testid",
Modal: "testid",
MoreMenu: "testid",
Select: "testid",
SplitButton: "testid"
};
const interactive = new Set([
"button",
"a",
"input",
"select",
"textarea",
"form",
"dialog",
"summary"
]);
const boundKeys = new Set(["value", "checked", "group", "files"]);
const instanceKeys = new Set(["open", "selected", "value"]);
// these spread rest onto their root, so data-component can land there
const rootComponents = new Set([
"Card",
"LayerCard",
"LayerCardPrimary",
"LayerCardSecondary",
"ButtonGroup"
]);
const transparent = new Set(["IfBlock", "EachBlock", "KeyBlock", "AwaitBlock", "SvelteBoundary"]);
const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
const camel = (parts: string[]) => parts[0] + parts.slice(1).map(cap).join("");
const slug = (text: string) => {
const words = text.split(/[^A-Za-z0-9]+/).filter(Boolean);
if (!words.length) return null;
const [first, ...rest] = words.slice(0, 4);
return first.charAt(0).toLowerCase() + first.slice(1) + rest.map(cap).join("");
};
const unwrap = (node: Node | null | undefined): Node | null | undefined => {
while (
node &&
(node.type === "ChainExpression" ||
node.type === "TSAsExpression" ||
node.type === "TSNonNullExpression" ||
node.type === "AwaitExpression" ||
(node.type === "UnaryExpression" && node.operator === "void"))
)
node = node.expression ?? node.argument;
return node;
};
const chain = (node: Node | null | undefined): string[] | null => {
node = unwrap(node);
if (!node) return null;
if (node.type === "Identifier") return [node.name];
if (node.type === "MemberExpression" && !node.computed) {
const object = chain(node.object);
return object && [...object, node.property.name];
}
return null;
};
const statementOf = (body: Node): Node | null => {
if (body.type !== "BlockStatement") return body;
if (body.body.length !== 1) return null;
const [only] = body.body;
return only.type === "ExpressionStatement" ? only.expression : only.argument;
};
const assignment = (node: Node) => {
const target = chain(node.left);
if (!target) return null;
const name = camel(target);
const stem = name.endsWith("Open") && name.length > 4 ? name.slice(0, -4) : "";
const { right } = node;
const flag = right.type === "Literal" ? right.value : undefined;
const toggles = right.type === "UnaryExpression" && right.operator === "!";
if (node.operator !== "=") return `set${cap(name)}`;
if (name === "open" && typeof flag === "boolean") return flag ? "open" : "close";
if (stem && typeof flag === "boolean") return `${flag ? "open" : "close"}${cap(stem)}`;
if (toggles) return `toggle${cap(name)}`;
if (flag === true) return `set${cap(name)}`;
if (flag === false) return `clear${cap(name)}`;
return `set${cap(name)}`;
};
// save.run(x) calls an action() helper, so the thing named is save
const callName = (parts: string[]) =>
camel(parts.length > 1 && parts.at(-1) === "run" ? parts.slice(0, -1) : parts);
const handler = (expr: Node | null | undefined) => {
let node = unwrap(expr);
if (!node) return null;
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
node = statementOf(node.body);
node = node && unwrap(node);
if (node?.type === "LogicalExpression") node = unwrap(node.right);
if (!node) return null;
if (node.type === "AssignmentExpression") return assignment(node);
if (node.type !== "CallExpression") return null;
const callee = chain(node.callee);
return callee && callName(callee);
}
const parts = chain(node);
return parts && callName(parts);
};
const staticText = (value: unknown) => {
if (!Array.isArray(value)) return null;
return value.every((part) => part.type === "Text")
? value.map((part) => part.data).join("")
: null;
};
const literal = (attribute: { value?: unknown }) => {
const text = staticText(attribute.value);
if (text !== null) return text;
const expr = (attribute.value as Node | undefined)?.expression;
if (expr?.type === "Literal" && typeof expr.value === "string") return expr.value;
return null;
};
// dynamic segments don't count, so /${handle}/issues is issues
const lastSegment = (path: string) => {
if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return null;
const segments = path
.split("?")[0]
.split("/")
.filter((segment) => segment && !segment.includes("\0"));
return segments.at(-1) ?? null;
};
const hrefName = (attribute: Attribute) => {
const value = "value" in attribute ? attribute.value : null;
const text = staticText(value);
if (text !== null) return slug(lastSegment(text) ?? "");
let expr = unwrap((value as Node | null)?.expression);
if (expr?.type === "CallExpression" && chain(expr.callee)?.at(-1) === "resolve")
expr = unwrap(expr.arguments[0]);
if (!expr) return null;
if (expr.type === "Literal" && typeof expr.value === "string")
return slug(lastSegment(expr.value) ?? "");
if (expr.type === "TemplateLiteral") {
const joined = expr.quasis.map((quasi: Node) => quasi.value.raw).join("\0");
return slug(lastSegment(joined) ?? "");
}
const parts = chain(expr);
return parts && camel(parts);
};
// inside a primitive these just pass the caller's prop along, so they name nothing
const forwarded = new Set([
"href",
"value",
"checked",
"group",
"files",
"onclick",
"ref",
"element"
]);
const named = (value: string | null | undefined, source: string): Found | null =>
value && !forwarded.has(value) ? { id: value, source } : null;
const action = (name: string) => {
const bare = name.match(/^on([A-Z].*)$/)?.[1] ?? name.match(/^on([a-z]+)$/)?.[1];
return bare ? bare.charAt(0).toLowerCase() + bare.slice(1) : name;
};
const find = (attributes: readonly Attribute[], name: string) =>
attributes.find(
(attribute): attribute is Extract<Attribute, { type: "Attribute" }> =>
attribute.type === "Attribute" && attribute.name === name
);
const fromHandler = (attributes: readonly Attribute[]) => {
for (const key of ["onclick", "onsubmit"]) {
const attribute = find(attributes, key);
const expression = (attribute?.value as Node | undefined)?.expression;
const id = expression && handler(expression);
const found = id && named(action(id) === "click" ? null : action(id), "handler");
if (found) return found;
}
return null;
};
const withoutOpen = (name: string) => (name.endsWith("Open") ? name.slice(0, -4) : name);
const fromBinding = (attributes: readonly Attribute[], keys = boundKeys) => {
for (const attribute of attributes) {
if (attribute.type !== "BindDirective" || !keys.has(attribute.name)) continue;
const parts = chain(attribute.expression as Node);
const name = parts && camel(parts);
if (attribute.name === "open" && name === "open") continue;
const found = name && named(attribute.name === "open" ? withoutOpen(name) : name, "bind");
if (found) return found;
}
return null;
};
const fromLiteral = (attributes: readonly Attribute[], keys: string[], source: string) => {
for (const key of keys) {
const attribute = find(attributes, key);
const text = attribute && literal(attribute);
const id = text && slug(text);
if (id) return named(id, source);
}
return null;
};
// strongest signal first, the label and placeholder ones come last because they move with the copy
const nameOf = (attributes: readonly Attribute[], instanced: boolean): Found | null => {
const bound = fromBinding(attributes, instanced ? instanceKeys : boundKeys);
const type = literal(find(attributes, "type") ?? {});
const href = find(attributes, "href");
const submit = type === "submit" ? { id: "submit", source: "submit" } : null;
const hrefId = href && named(hrefName(href), "href");
const code = fromLiteral(attributes, ["id", "name"], "attr");
const copy = fromLiteral(attributes, ["aria-label", "label", "placeholder"], "copy");
return fromHandler(attributes) ?? bound ?? hrefId ?? submit ?? code ?? copy;
};
const collectRoots = (nodes: Node[] | undefined, roots: Element[] = []) => {
for (const node of nodes ?? []) {
if (
node.type === "RegularElement" ||
(node.type === "Component" && rootComponents.has(node.name))
)
roots.push(node as Element);
else if (transparent.has(node.type)) {
collectRoots(node.fragment?.nodes, roots);
collectRoots(node.consequent?.nodes, roots);
collectRoots(node.alternate?.nodes, roots);
collectRoots(node.body?.nodes, roots);
collectRoots(node.fallback?.nodes, roots);
collectRoots(node.pending?.nodes, roots);
collectRoots(node.then?.nodes, roots);
collectRoots(node.catch?.nodes, roots);
}
}
return roots;
};
const visit = (node: unknown, fn: (node: Node) => void): void => {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) return node.forEach((child) => visit(child, fn));
if (typeof (node as Node).type === "string") fn(node as Node);
for (const [key, value] of Object.entries(node)) {
if (key === "expression" || key === "metadata") continue;
visit(value, fn);
}
};
const lineOf = (source: string, offset: number) => source.slice(0, offset).split("\n").length;
export function transform(source: string, { component, attribute = "data-testid" }: Options = {}) {
const ast = parse(source, { modern: true });
const inserts: Insert[] = [];
const seen: Seen[] = [];
const insert = (node: Element, name: string, value: string) => {
inserts.push({ at: node.start + 1 + node.name.length, text: ` ${name}="${value}"` });
};
const roots = new Set(collectRoots(ast.fragment.nodes as Node[]));
const used: Record<string, number> = {};
visit(ast.fragment, (node) => {
if (node.type === "Component") used[node.name] = (used[node.name] ?? 0) + 1;
});
visit(ast.fragment, (node) => {
const isElement = node.type === "RegularElement";
const isComponent = node.type === "Component" && node.name in components;
const anyComponent = node.type === "Component";
if (!isElement && !anyComponent) return;
const element = node as Element;
const { attributes } = element;
const handled = isElement && find(attributes, "onclick");
const wanted = isComponent || interactive.has(element.name) || handled;
const line = lineOf(source, element.start);
if (component && roots.has(element) && !find(attributes, "data-component"))
insert(element, "data-component", component);
const target = isComponent ? components[element.name] : attribute;
const written =
find(attributes, attribute) ?? (target !== attribute && find(attributes, target));
if (written) {
const id = literal(written) ?? "(dynamic)";
seen.push({ line, id, source: "hand" });
return;
}
if (!isElement && !isComponent) return;
if (!wanted) return;
const instanced = isComponent && target === "testid";
// a second one would need an ordinal, and ordinals drift
const alone = instanced && used[element.name] === 1;
const found =
nameOf(attributes, instanced) ??
(alone
? {
id: element.name.charAt(0).toLowerCase() + element.name.slice(1),
source: "tag"
}
: null);
seen.push({ line, id: found?.id ?? null, source: found?.source ?? null });
if (found) insert(element, target, found.id);
});
return { inserts, seen };
}
export function apply(source: string, inserts: Insert[]) {
let out = source;
for (const { at, text } of [...inserts].sort((a, b) => b.at - a.at)) {
out = out.slice(0, at) + text + out.slice(at);
}
return out;
}
const skipped = /(?:^|\/)(?:node_modules|\.storybook)\/|\.stories\.svelte$/;
export const componentName = (filename: string) => {
const route = filename.match(/src\/routes\/(.*)\/\+(page|layout|error)\.svelte$/);
if (route) return `${route[2]}:${route[1].replace(/\/\([^/]+\)/g, "")}`;
return filename
.split("/")
.pop()!
.replace(/\.svelte$/, "");
};
export function testids(options: Options = {}) {
return {
name: "testids",
markup({ content, filename }: { content: string; filename?: string }) {
if (!filename || skipped.test(filename) || !filename.endsWith(".svelte")) return;
const { inserts } = transform(content, {
component: componentName(filename),
...options
});
if (!inserts.length) return;
return { code: apply(content, inserts) };
}
};
}