Files
openscreen/src/lib/wallpaper.test.ts
T
Enriquefft f2ff7fb21c address review audit: persist canonical wallpaper, dedupe types, tighten edge cases
R1 — Persisted wallpaper is now always the canonical /wallpapers/wallpaperN.jpg
form, never the resolved file:// URL. Swatch clicks pass WALLPAPER_PATHS[i]
(the relative path) to onWallpaperChange; the resolved URL stays in
wallpaperPreviewUrls for rendering only. This prevents machine-specific paths
from being written into project JSON and avoids break-on-upgrade /
break-on-share regressions. Legacy projects carrying resolved file:// URLs are
rewritten by a new normalizer in normalizeProjectEditor:
file://…(/assets)?/wallpapers/wallpaperN.jpg → /wallpapers/wallpaperN.jpg.

R2 — resolveImageWallpaperUrl now catches anything getAssetPath throws
(UnsafeAssetPathError, AssetBaseUnavailableError) and rewraps as
BackgroundLoadError with the original as cause. Callers (videoExporter retry
loop, gifExporter catch, VideoEditor toast) only need one instanceof check and
users always see the translated errors.exportBackgroundLoadFailed toast.

R3 — src/vite-env.d.ts no longer duplicates Window.electronAPI. The interface
had drifted — renderer declaration was missing readBinaryFile, getPlatform,
revealInFolder, getShortcuts, saveShortcuts, hudOverlay*, countdown overlay
methods that electron-env.d.ts already declares. Removed the duplicate and
kept the triple-slash reference so the authoritative declaration is the one
in electron/electron-env.d.ts.

N1 — GRADIENT_RE accepts optional "repeating-" prefix so
repeating-linear/radial/conic-gradient values classify as gradients instead
of falling through to color.

N2 — displayBasename returns "(unknown)" sentinel for URLs without a
meaningful basename (file:///, bare /) instead of leaking the original string.

N3 — electron-builder.json5 extraResources block gets an inline comment
pointing at preload.ts:assetBaseDir so the bidirectional coupling is
discoverable from either file.

Tests: 54 unit tests pass (up from 35). New coverage for repeating
gradients, displayBasename sentinels, BackgroundLoadError cause wrapping,
legacy file:// wallpaper normalization (5 cases).
2026-04-24 18:55:04 -05:00

270 lines
7.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AssetBaseUnavailableError, UnsafeAssetPathError } from "./assetPath";
import {
BackgroundLoadError,
classifyWallpaper,
DEFAULT_WALLPAPER,
resolveImageWallpaperUrl,
WALLPAPER_COUNT,
WALLPAPER_PATHS,
} from "./wallpaper";
describe("WALLPAPER_PATHS", () => {
it("contains WALLPAPER_COUNT entries", () => {
expect(WALLPAPER_PATHS).toHaveLength(WALLPAPER_COUNT);
});
it("DEFAULT_WALLPAPER is WALLPAPER_PATHS[0]", () => {
expect(DEFAULT_WALLPAPER).toBe(WALLPAPER_PATHS[0]);
});
});
describe("classifyWallpaper", () => {
it("hex color", () => {
expect(classifyWallpaper("#1a1a2e")).toEqual({ kind: "color", value: "#1a1a2e" });
});
it("rgb() color", () => {
expect(classifyWallpaper("rgb(1, 2, 3)")).toEqual({ kind: "color", value: "rgb(1, 2, 3)" });
});
it("rgba() color", () => {
expect(classifyWallpaper("rgba(1, 2, 3, 0.5)")).toEqual({
kind: "color",
value: "rgba(1, 2, 3, 0.5)",
});
});
it("hsl() color", () => {
expect(classifyWallpaper("hsl(180, 50%, 50%)")).toEqual({
kind: "color",
value: "hsl(180, 50%, 50%)",
});
});
it("oklch() color", () => {
expect(classifyWallpaper("oklch(50% 0.1 180)")).toEqual({
kind: "color",
value: "oklch(50% 0.1 180)",
});
});
it("linear gradient", () => {
const v = "linear-gradient(90deg, red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("radial gradient", () => {
const v = "radial-gradient(circle, red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("conic gradient", () => {
const v = "conic-gradient(red, blue)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("repeating-linear gradient", () => {
const v = "repeating-linear-gradient(45deg, red 0 10px, blue 10px 20px)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("repeating-radial gradient", () => {
const v = "repeating-radial-gradient(circle, red, blue 20px)";
expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
});
it("leading-slash image path", () => {
expect(classifyWallpaper("/wallpapers/wallpaper1.jpg")).toEqual({
kind: "image",
path: "/wallpapers/wallpaper1.jpg",
});
});
it("http URL as image", () => {
expect(classifyWallpaper("https://example.com/bg.jpg")).toEqual({
kind: "image",
path: "https://example.com/bg.jpg",
});
});
it("file:// URL as image", () => {
expect(classifyWallpaper("file:///tmp/bg.jpg")).toEqual({
kind: "image",
path: "file:///tmp/bg.jpg",
});
});
it("data URI as image", () => {
expect(classifyWallpaper("data:image/png;base64,AAA")).toEqual({
kind: "image",
path: "data:image/png;base64,AAA",
});
});
it("named color falls back to color", () => {
expect(classifyWallpaper("red")).toEqual({ kind: "color", value: "red" });
});
it("empty string falls back to black", () => {
expect(classifyWallpaper("")).toEqual({ kind: "color", value: "#000000" });
});
it("trims whitespace", () => {
expect(classifyWallpaper(" #abcdef ")).toEqual({ kind: "color", value: "#abcdef" });
});
it("DEFAULT_WALLPAPER classifies as image", () => {
expect(classifyWallpaper(DEFAULT_WALLPAPER)).toEqual({
kind: "image",
path: DEFAULT_WALLPAPER,
});
});
});
describe("resolveImageWallpaperUrl", () => {
beforeEach(() => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "http:" },
electronAPI: undefined,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes through http URL", () => {
expect(resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe("http://example.com/bg.jpg");
});
it("passes through https URL", () => {
expect(resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
"https://example.com/bg.jpg",
);
});
it("passes through file:// URL", () => {
expect(resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg");
});
it("passes through data URI", () => {
const uri = "data:image/png;base64,AAAA";
expect(resolveImageWallpaperUrl(uri)).toBe(uri);
});
it("resolves leading-slash wallpaper path via http fallback", () => {
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("resolves bare relative wallpaper path", () => {
expect(resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
it("encodes special characters in path segments", () => {
expect(resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe("/wallpapers/my%20image.jpg");
});
it("rejects image paths outside /wallpapers/", () => {
expect(() => resolveImageWallpaperUrl("/etc/passwd")).toThrow(BackgroundLoadError);
});
it("wraps traversal attempts in BackgroundLoadError (preserves UnsafeAssetPathError as cause)", () => {
try {
resolveImageWallpaperUrl("/wallpapers/../etc/passwd");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BackgroundLoadError);
expect((err as BackgroundLoadError).cause).toBeInstanceOf(UnsafeAssetPathError);
}
});
it("wraps percent-encoded traversal in BackgroundLoadError", () => {
try {
resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BackgroundLoadError);
expect((err as BackgroundLoadError).cause).toBeInstanceOf(UnsafeAssetPathError);
}
});
it("resolves via electronAPI.assetBaseUrl when not http", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "file:///opt/app/public/" },
});
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg",
);
});
it("appends trailing slash to assetBaseUrl if missing", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "file:///opt/app/public" },
});
expect(resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"file:///opt/app/public/wallpapers/wallpaper1.jpg",
);
});
it("wraps AssetBaseUnavailableError in BackgroundLoadError when assetBaseUrl is empty", () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
electronAPI: { assetBaseUrl: "" },
});
try {
resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BackgroundLoadError);
expect((err as BackgroundLoadError).cause).toBeInstanceOf(AssetBaseUnavailableError);
}
});
});
describe("BackgroundLoadError", () => {
it("carries the failing URL and is instanceof Error", () => {
const err = new BackgroundLoadError("/home/user/secret/wallpaper.jpg");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(BackgroundLoadError);
expect(err.url).toBe("/home/user/secret/wallpaper.jpg");
expect(err.name).toBe("BackgroundLoadError");
});
it("displayUrl hides parent directories to avoid leaking PII", () => {
const err = new BackgroundLoadError("file:///home/enrique/projects/openscreen/wallpaper1.jpg");
expect(err.displayUrl).toBe("wallpaper1.jpg");
});
it("displayUrl abbreviates data URIs", () => {
const err = new BackgroundLoadError("data:image/png;base64,AAA");
expect(err.displayUrl).toBe("data:…");
});
it("displayUrl returns sentinel for empty-basename URLs", () => {
const err = new BackgroundLoadError("file:///");
expect(err.displayUrl).toBe("(unknown)");
});
it("displayUrl returns sentinel for unparseable bare slash", () => {
const err = new BackgroundLoadError("/");
expect(err.displayUrl).toBe("(unknown)");
});
it("preserves cause when provided", () => {
const cause = new Error("inner");
const err = new BackgroundLoadError("file:///missing.jpg", cause);
expect(err.cause).toBe(cause);
});
});