feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OptimizationCancelledError,
|
||||
OptimizerClient,
|
||||
} from "../../src/optimization/optimizer-client";
|
||||
import type {
|
||||
OptimizationRequest,
|
||||
OptimizationResult,
|
||||
} from "../../src/optimization/optimization.types";
|
||||
|
||||
class TestWorker extends EventTarget {
|
||||
static instances: TestWorker[] = [];
|
||||
|
||||
readonly messages: unknown[] = [];
|
||||
terminateCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
TestWorker.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminateCount += 1;
|
||||
}
|
||||
|
||||
respond(response: OptimizationResult): void {
|
||||
this.dispatchEvent(new MessageEvent("message", { data: response }));
|
||||
}
|
||||
}
|
||||
|
||||
function resultFor(worker: TestWorker, source = "<svg/>"): OptimizationResult {
|
||||
const request = worker.messages[0] as OptimizationRequest;
|
||||
return {
|
||||
type: "result",
|
||||
jobId: request.jobId,
|
||||
source,
|
||||
profile: request.profile,
|
||||
optionalPlugins: request.optionalPlugins,
|
||||
inputBytes: source.length,
|
||||
outputBytes: source.length,
|
||||
elapsedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OptimizerClient cancellation", () => {
|
||||
beforeEach(() => {
|
||||
TestWorker.instances = [];
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("Worker", TestWorker);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects a cancelled job immediately and cleans up exactly once", async () => {
|
||||
const client = new OptimizerClient();
|
||||
const pending = client.optimize("<svg/>", "conservative");
|
||||
const rejected = expect(pending).rejects.toBeInstanceOf(
|
||||
OptimizationCancelledError,
|
||||
);
|
||||
const worker = TestWorker.instances[0]!;
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
client.cancel();
|
||||
client.cancel();
|
||||
|
||||
await rejected;
|
||||
expect(worker.terminateCount).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
worker.respond(resultFor(worker, '<svg id="late"/>'));
|
||||
expect(worker.terminateCount).toBe(1);
|
||||
});
|
||||
|
||||
it("promptly rejects a superseded job while the replacement can finish", async () => {
|
||||
const client = new OptimizerClient();
|
||||
const first = client.optimize('<svg id="first"/>', "conservative");
|
||||
const firstRejected = expect(first).rejects.toBeInstanceOf(
|
||||
OptimizationCancelledError,
|
||||
);
|
||||
const firstWorker = TestWorker.instances[0]!;
|
||||
|
||||
const second = client.optimize('<svg id="second"/>', "standard");
|
||||
const secondWorker = TestWorker.instances[1]!;
|
||||
await firstRejected;
|
||||
|
||||
expect(firstWorker.terminateCount).toBe(1);
|
||||
expect(secondWorker.terminateCount).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
const expected = resultFor(secondWorker, '<svg id="optimized"/>');
|
||||
secondWorker.respond(expected);
|
||||
await expect(second).resolves.toEqual(expected);
|
||||
expect(secondWorker.terminateCount).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
client.cancel();
|
||||
expect(secondWorker.terminateCount).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the same race-safe cleanup for AbortSignal cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const client = new OptimizerClient();
|
||||
const pending = client.optimize(
|
||||
"<svg/>",
|
||||
"aggressive",
|
||||
[],
|
||||
controller.signal,
|
||||
);
|
||||
const rejected = expect(pending).rejects.toBeInstanceOf(
|
||||
OptimizationCancelledError,
|
||||
);
|
||||
const worker = TestWorker.instances[0]!;
|
||||
|
||||
controller.abort();
|
||||
await rejected;
|
||||
|
||||
expect(worker.terminateCount).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
client.cancel();
|
||||
expect(worker.terminateCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
configForProfile,
|
||||
optimizationProfiles,
|
||||
} from "../../src/optimization/profiles";
|
||||
|
||||
describe("explicit optimization profiles", () => {
|
||||
it("exposes three clearly ordered risk levels", () => {
|
||||
expect(optimizationProfiles.map((profile) => profile.id)).toEqual([
|
||||
"conservative",
|
||||
"standard",
|
||||
"aggressive",
|
||||
]);
|
||||
expect(
|
||||
optimizationProfiles.every(
|
||||
(profile) => profile.description && profile.risk,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains IDs and viewBox in every profile", () => {
|
||||
for (const name of ["conservative", "standard", "aggressive"] as const) {
|
||||
const config = configForProfile(name);
|
||||
const preset = config.plugins?.find(
|
||||
(plugin) =>
|
||||
typeof plugin === "object" && plugin.name === "preset-default",
|
||||
);
|
||||
expect(preset).toMatchObject({
|
||||
params: { overrides: { cleanupIds: false, removeViewBox: false } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("only enables multipass and structural rewrites in aggressive mode", () => {
|
||||
expect(configForProfile("conservative").multipass).toBe(false);
|
||||
expect(configForProfile("standard").multipass).toBe(false);
|
||||
const aggressive = configForProfile("aggressive");
|
||||
expect(aggressive.multipass).toBe(true);
|
||||
expect(aggressive.plugins).toEqual(
|
||||
expect.arrayContaining(["convertShapeToPath", "collapseGroups"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds only explicitly allow-listed optional plugins", () => {
|
||||
const config = configForProfile("conservative", [
|
||||
"removeDimensions",
|
||||
"reusePaths",
|
||||
"removeDimensions",
|
||||
]);
|
||||
expect(config.plugins).toEqual(
|
||||
expect.arrayContaining(["removeDimensions", "reusePaths"]),
|
||||
);
|
||||
expect(
|
||||
config.plugins?.filter((plugin) => plugin === "removeDimensions"),
|
||||
).toHaveLength(1);
|
||||
expect(() =>
|
||||
configForProfile("standard", ["unknown" as "reusePaths"]),
|
||||
).toThrow("Unsupported optional SVGO plugin");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user