test(plugins): cover indexed marker icon races and fallback (#2198)

Tests only: stale icon resolution results are dropped, and markers fall back to the navigation glyph when a declaration goes away.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 05:06:41 +08:00
1 джерело 95047cefb3
коміт 064d4506cd
2 files changed
+130 -2

No files matched your search

+114 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorState, type RangeSet, type StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { offsetsToRange } from "@/editor/lsp/positions";
import type { FileMarkers } from "@/platform/ipc";
@@ -17,6 +17,7 @@ afterEach(() => {
view?.destroy();
view = undefined;
usePluginMarkers.setState({ entries: new Map() });
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -38,6 +39,13 @@ function setup() {
return { entry, target };
}
const button = () => view!.dom.querySelector<HTMLButtonElement>(".cm-plugin-mapping-button");
function pendingAssets() {
let resolve!: (assets: [string, string][]) => void;
const promise = new Promise<[string, string][]>((done) => {
resolve = done;
});
return { promise, resolve };
}
it("puts a button on the marker's line that goes to its target", async () => {
const { target } = setup();
@@ -114,3 +122,108 @@ it("redraws when a marker icon declaration goes away", async () => {
await vi.waitFor(() => expect(button()!.querySelector("circle")).toBeNull());
expect(button()!.title).toBe("Query.java:1");
});
it.each(["edit", "remove", "replace", "dispose"])("drops pending icons after %s", async (change) => {
vi.useFakeTimers();
const pending = pendingAssets();
calls.assets.mockReturnValue(pending.promise);
const remove = projectMarkerIcon("p", { id: "slow", icon: "icons/slow.svg", tooltip: "Old marker" });
try {
const { entry } = setup();
usePluginMarkers.setState({
entries: new Map([[entry.path, { ...entry, markers: [{ ...entry.markers[0]!, icon: "slow" }] }]]),
});
await vi.advanceTimersByTimeAsync(0);
if (change === "edit") view!.dispatch({ changes: { from: 0, to: view!.state.doc.length, insert: "short" } });
if (change === "remove") usePluginMarkers.setState({ entries: new Map() });
if (change === "replace") usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) });
if (change === "dispose") view!.destroy();
await vi.advanceTimersByTimeAsync(0);
const dispatch = vi.spyOn(view!, "dispatch");
pending.resolve([["markerIcon/slow/light", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>']]);
await vi.advanceTimersByTimeAsync(0);
expect(dispatch).not.toHaveBeenCalled();
expect(button()?.querySelector("circle")).toBeFalsy();
if (change === "replace") expect(button()!.title).toBe("Query.java:1");
else expect(button()).toBeNull();
if (change === "dispose") view = undefined;
} finally {
remove();
}
});
it("positions a new indexed result against the edited document while icons load", async () => {
vi.useFakeTimers();
const pending = pendingAssets();
calls.assets.mockReturnValue(pending.promise);
const remove = projectMarkerIcon("p", { id: "slow", icon: "icons/slow.svg" });
try {
const { entry } = setup();
const marker = { ...entry.markers[0]!, icon: "slow" };
usePluginMarkers.setState({ entries: new Map([[entry.path, { ...entry, markers: [marker] }]]) });
await vi.advanceTimersByTimeAsync(0);
view!.dispatch({ changes: { from: 0, insert: "a longer first line\n" } });
const range = {
start: { ...marker.range.start, line: marker.range.start.line + 1 },
end: { ...marker.range.end, line: marker.range.end.line + 1 },
};
usePluginMarkers.setState({
entries: new Map([[entry.path, { ...entry, version: 2, markers: [{ ...marker, range }] }]]),
});
await vi.advanceTimersByTimeAsync(0);
const dispatch = vi.spyOn(view!, "dispatch");
pending.resolve([["markerIcon/slow/light", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>']]);
await vi.advanceTimersByTimeAsync(0);
expect(button()!.querySelector("circle")).not.toBeNull();
const transaction = dispatch.mock.calls[0]![0] as { effects: StateEffect<RangeSet<never>> };
expect(transaction.effects.value.iter().from).toBe(view!.state.doc.line(3).from);
} finally {
remove();
}
});
it("merges two plugins on one line and uses the first declared icon with a plain-text tooltip", async () => {
calls.assets.mockImplementation((pluginId: string) =>
Promise.resolve([
[
"markerIcon/shared/light",
pluginId === "first"
? '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>'
: '<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>',
],
]),
);
const tooltip = '<img src="invalid" onerror="alert(1)"> & usages';
const removers = ["first", "second"].map((id) =>
projectMarkerIcon(id, { id: "shared", icon: "icons/shared.svg", tooltip: id === "first" ? tooltip : "Second" }),
);
try {
const { entry, target } = setup();
const other = { ...target, path: "/p/Other.java" };
usePluginMarkers.setState({
entries: new Map([
[
entry.path,
{
...entry,
markers: [
{ ...entry.markers[0]!, pluginId: "first", icon: "shared" },
{ ...entry.markers[0]!, pluginId: "second", icon: "shared", targets: [target, other] },
],
},
],
]),
});
await vi.waitFor(() => expect(button()!.querySelector("circle")).not.toBeNull());
expect(view!.dom.querySelectorAll(".cm-plugin-mapping-button")).toHaveLength(1);
expect(button()!.querySelector("rect, img")).toBeNull();
expect(button()!.title).toBe(`${tooltip}\nQuery.java:1\nOther.java:1`);
button()!.click();
await vi.waitFor(() => expect(calls.popup).toHaveBeenCalledOnce());
removers[0]!();
await vi.waitFor(() => expect(button()!.querySelector("rect")).not.toBeNull());
expect(button()!.title).toBe("Second\nQuery.java:1\nOther.java:1");
} finally {
removers.forEach((remove) => remove());
}
});
+16 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { afterEach, expect, it, vi } from "vitest";
import { gutterIconMarkup } from "@/ui/icons";
import { projectMarkerIcon, resolveMarkerIcon } from "./markerIcons";
import { projectMarkerIcon, resolveIndexedMarkerIcon, resolveMarkerIcon } from "./markerIcons";
import { markerIcon } from "./lineMarkerGutter";
const mocks = vi.hoisted(() => ({ assets: vi.fn() }));
@@ -42,7 +42,22 @@ it("keeps a replacement when an older declaration is disposed", async () => {
const removeNew = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" });
removeOld();
expect(await resolveMarkerIcon("p", "icons/resource.svg")).toMatch(/^ext:/);
expect((await resolveIndexedMarkerIcon("p", "resource"))?.icon).toMatch(/^ext:/);
removeNew();
expect(await resolveIndexedMarkerIcon("p", "resource")).toBeUndefined();
});
it("preserves line-marker lookup with projected indexed-marker defaults", async () => {
mocks.assets.mockResolvedValue([["markerIcon/resource/light", svg('<rect width="16" height="16"/>')]]);
const declaration = { id: "resource", icon: "icons/resource.svg", markers: [], tooltip: null };
const remove = projectMarkerIcon("p", declaration);
try {
const icon = await resolveMarkerIcon("p", declaration.icon);
expect(icon).toMatch(/^ext:/);
expect(await resolveIndexedMarkerIcon("p", declaration.id)).toEqual({ icon, tooltip: null });
} finally {
remove();
}
});
it("falls back when an asset fails to load", async () => {