Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"test:appearance": "node --conditions=browser --test scripts/test_appearance.mjs",
"test:window": "node --conditions=browser --test scripts/test_window_activity.mjs scripts/test_library_ui.mjs",
"test:dialog": "node --conditions=browser --test scripts/test_dialog.mjs",
"test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs scripts/test_game_identity.mjs scripts/test_game_library.mjs",
"test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs scripts/test_game_identity.mjs scripts/test_game_library.mjs scripts/test_steam_video_loop.mjs scripts/test_gallery.mjs scripts/test_steam_trailer.mjs",
"test:onboarding": "node --test scripts/test_onboarding_news.mjs",
"test:sources": "node --conditions=browser --test scripts/test_sources.mjs scripts/test_hydra_sources.mjs scripts/test_download_mirrors.mjs",
"test:catalog": "node --conditions=browser --test scripts/test_catalog.mjs scripts/test_store_filters.mjs",
Expand All @@ -43,7 +43,8 @@
"@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.4",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.13.2"
"@tauri-apps/plugin-updater": "^2.13.2",
"hls.js": "^1.7.4"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions scripts/test_dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,20 @@ test("dialog fades its surface and content without isolating the surface backdro
try {
assert.equal(dom.window.getComputedStyle(dialog).opacity, "1", "opening dialog must not isolate the blurred surface");
const content = dialog.querySelector(".legio-dialog-content");
const surface = dialog.querySelector(".legio-dialog-surface");
assert.equal(dom.window.getComputedStyle(surface).opacity, "0", "the empty panel must not appear before its content");
assert.equal(dom.window.getComputedStyle(content).opacity, "0");
advanceFrame();
advanceFrame();
flushSync();
assert.equal(dom.window.getComputedStyle(dialog).opacity, "1");
assert.equal(dom.window.getComputedStyle(content).opacity, "1");
assert.equal(dom.window.getComputedStyle(surface).opacity, "1");
[...dialog.querySelectorAll("button")].find(button => button.textContent === "Close").click();
flushSync();
assert.equal(dom.window.getComputedStyle(dialog).opacity, "1", "closing dialog must retain access to the page backdrop");
assert.equal(dom.window.getComputedStyle(content).opacity, "0");
assert.equal(dom.window.getComputedStyle(surface).opacity, "0", "the empty panel must not remain after its content fades");
} finally {
await unmount(instance);
}
Expand Down
87 changes: 87 additions & 0 deletions scripts/test_gallery.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { JSDOM } from "jsdom";
import { compile } from "svelte/compiler";
import { createModuleLoader, dataModule } from "./test_module_loader.mjs";
const dom = new JSDOM("<!doctype html><html><body></body></html>");
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLMediaElement", "Element", "Node", "Text", "Comment", "Event", "MutationObserver"]) Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] });
dom.window.HTMLElement.prototype.scrollIntoView = function () {};
globalThis.matchMedia = () => ({ matches: true });
const { mount, unmount, flushSync } = await import("svelte");
const stub = async source => dataModule(await createModuleLoader({}).resolveImports(compile(source, { generate: "client" }).js.code, "stub.svelte"));
const store = import.meta.resolve("svelte/store");
const { load } = createModuleLoader({
"src/lib/i18n": dataModule(`import { writable } from ${JSON.stringify(store)}; export const language=writable('it'); export const t=(text,_lang,args=[])=>text.replace(/\\{(\\d+)\\}/g,(_,i)=>args[i]);`),
"src/lib/features/library/SteamArtwork.svelte": await stub('<script>let {alt="",index}=$props();</script><img {alt} data-screenshot={index} />'),
"src/lib/features/library/ArtworkViewer.svelte": await stub('<script>let {index,onClose}=$props();</script><div role="dialog" aria-label="Zoom" data-index={index}><button onclick={onClose}>Close</button></div>'),
"src/lib/services/steam-trailer": dataModule('export function loadSteamTrailer(video,movie,onError){globalThis.galleryFixture.opened.push(movie.id);globalThis.galleryFixture.onError=onError;return ()=>globalThis.galleryFixture.closed.push(movie.id);}'),
});

test("trailers and screenshots share a gallery and changing media stops playback", async () => {
globalThis.galleryFixture = { opened: [], closed: [] };
const Component = (await import(await load("src/lib/features/library/ScreenshotGallery.svelte"))).default;
const target = document.createElement("div");document.body.append(target);
const instance = mount(Component, {target,props:{steamAppId:400,name:"Portal",screenshots:[{thumbnail:"https://cdn.steamstatic.com/s.jpg",full:"https://cdn.steamstatic.com/full.jpg"}],movies:[{id:922,name:"Portal Trailer",thumbnail:"https://cdn.steamstatic.com/m.jpg",hls:"https://video.akamai.steamstatic.com/movie.m3u8",mp4:null,webm:null}]}});
try {
flushSync();
const video = target.querySelector("video");
assert.ok(video, "the first trailer appears alongside the screenshots");
assert.equal(video.controls, false);
assert.equal(video.autoplay, false);
const screenshots = [...target.querySelectorAll("button")].filter(button=>button.getAttribute("aria-label")==="Mostra schermata 1");
screenshots[0].click();flushSync();
assert.equal(target.querySelector("video"), null);
assert.deepEqual(globalThis.galleryFixture.closed, [922]);
[...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")==="Ingrandisci schermata 1 di 1").click();flushSync();
assert.equal(target.querySelector('[role="dialog"]').dataset.index, "0", "trailer slots must not offset screenshot zoom indices");
target.querySelector('[role="dialog"] button').click();flushSync();
[...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")==="Mostra trailer: Portal Trailer").click();flushSync();
assert.ok(target.querySelector("video"));
} finally { await unmount(instance);target.remove();delete globalThis.galleryFixture; }
});


test("custom trailer controls play, seek, mute and change volume", async () => {
globalThis.galleryFixture = { opened: [], closed: [] };
const Component = (await import(await load("src/lib/features/library/TrailerPlayer.svelte"))).default;
const target = document.createElement("div"); document.body.append(target);
const instance = mount(Component, {target,props:{movie:{id:922,name:"Portal",thumbnail:null,hls:null,mp4:"https://cdn.steamstatic.com/movie.mp4",webm:null}}});
let plays=0;let pauses=0;
try {
flushSync();const video=target.querySelector("video");
video.play=async()=>{plays++;video.dispatchEvent(new Event("play"));};
video.pause=()=>{pauses++;video.dispatchEvent(new Event("pause"));};
Object.defineProperty(video,"duration",{value:90});
video.dispatchEvent(new Event("loadedmetadata"));flushSync();
const button=name=>[...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")===name);
assert.equal(video.controls,false);
assert.ok(button("Riproduci trailer"));
button("Riproduci trailer").click();await new Promise(resolve=>setImmediate(resolve));flushSync();
assert.equal(plays,1);button("Metti in pausa").click();flushSync();assert.equal(pauses,1);
const seek=target.querySelector('input[aria-label="Avanzamento del trailer"]');
assert.equal(seek.disabled,false);seek.value="45";seek.dispatchEvent(new Event("input",{bubbles:true}));flushSync();
assert.equal(video.currentTime,45);assert.ok(target.textContent.includes("0:45 / 1:30"));
button("Disattiva audio").click();flushSync();assert.equal(video.muted,true);
const volume=target.querySelector('input[aria-label="Volume del trailer"]');volume.value="0.4";
volume.dispatchEvent(new Event("input",{bubbles:true}));flushSync();assert.equal(video.volume,0.4);assert.equal(video.muted,false);
} finally { await unmount(instance);target.remove();delete globalThis.galleryFixture; }
});


test("failed trailers show a useful retry action and replace the failed player", async () => {
globalThis.galleryFixture={opened:[],closed:[]};
const Component=(await import(await load("src/lib/features/library/TrailerPlayer.svelte"))).default;
const target=document.createElement("div");document.body.append(target);
const instance=mount(Component,{target,props:{movie:{id:7,name:"Test",thumbnail:null,hls:"https://video.akamai.steamstatic.com/test.m3u8",mp4:null,webm:null}}});
try {
flushSync();
globalThis.galleryFixture.onError("Connection reset by peer");flushSync();
assert.ok(target.querySelector('[role="alert"]'));
assert.equal(target.textContent.includes("Connection reset by peer"),false,"raw network errors must not replace the page or reach the user");
const retry=[...target.querySelectorAll("button")].find(button=>button.textContent.trim()==="Riprova");
assert.ok(retry);retry.click();flushSync();
assert.equal(target.querySelector('[role="alert"]'),null);
assert.deepEqual(globalThis.galleryFixture.opened,[7,7]);
assert.deepEqual(globalThis.galleryFixture.closed,[7]);
} finally {await unmount(instance);target.remove();delete globalThis.galleryFixture;}
});
135 changes: 135 additions & 0 deletions scripts/test_game_library.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputE
const { mount, unmount, flushSync } = await import("svelte");
const { writable } = await import("svelte/store");
globalThis.matchMedia = () => ({ matches: false });
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
dom.window.HTMLMediaElement.prototype.pause = function () {};
const settle = async () => { for (let i = 0; i < 5; i++) { await new Promise(resolve => setImmediate(resolve)); flushSync(); } };

test("uninstall previews the exact directory and requires a second confirmation", async () => {
Expand Down Expand Up @@ -144,6 +146,139 @@ test("Steam descriptions keep link text without links or scripts", async () => {
delete globalThis.DOMParser;
});

test("Steam description images and GIFs survive sanitization", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const target = document.createElement("div");
try {
const gif = "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/demo.gif";
const image = "https://images.steamusercontent.com/demo.png";
const renderer = renderSteamDescription(target, `<p>Gameplay<img src="${gif}" alt="Gameplay preview" onerror="alert(1)" style="position:fixed" srcset="https://evil.test/image.png 2x"></p><a href="https://evil.test"><img src="${image}"></a>`);
const images = [...target.querySelectorAll("img")];
assert.equal(images.length, 2);
assert.equal(images[0].src, gif);
assert.equal(images[0].alt, "Gameplay preview");
assert.equal(images[1].src, image);
assert.equal(images[1].alt, "");
assert.equal(images[0].getAttribute("loading"), "lazy");
assert.equal(images[0].getAttribute("referrerpolicy"), "no-referrer");
assert.equal(target.querySelector("[onerror],[style],[srcset],a"), null);
renderer.update("<p>New description</p>");
assert.equal(target.querySelector("img"), null);
assert.equal(target.textContent, "New description");
} finally { delete globalThis.DOMParser; }
});

test("Steam GIFs suspend decoding outside the viewport and resume near it", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const observers = [];
globalThis.IntersectionObserver = class {
constructor(callback, options) { this.callback = callback; this.options = options; this.targets = new Set(); observers.push(this); }
observe(target) { this.targets.add(target); }
disconnect() { this.targets.clear(); this.disconnected = true; }
intersect(target, isIntersecting) { this.callback([{ target, isIntersecting }]); }
};
const target = document.createElement("div");
try {
const renderer = renderSteamDescription(target, '<img src="https://shared.fastly.steamstatic.com/demo.gif"><img src="https://shared.fastly.steamstatic.com/demo.png">');
const [gif, image] = target.querySelectorAll("img");
assert.equal(observers.length, 1);
assert.equal(observers[0].options.rootMargin, "300px 0px");
assert.deepEqual([...observers[0].targets], [gif]);
Object.defineProperties(gif, { complete: { configurable: true, value: true }, naturalWidth: { configurable: true, value: 640 }, naturalHeight: { configurable: true, value: 360 } });
observers[0].intersect(gif, false);
gif.dispatchEvent(new dom.window.Event("load"));
assert.equal(gif.hasAttribute("src"), false, "offscreen GIFs release their animated source after loading");
assert.equal(gif.getAttribute("width"), "640");
assert.equal(gif.getAttribute("height"), "360");
observers[0].intersect(gif, true);
assert.equal(gif.src, "https://shared.fastly.steamstatic.com/demo.gif");
assert.equal(image.src, "https://shared.fastly.steamstatic.com/demo.png", "static images stay loaded");
renderer.destroy();
assert.equal(observers[0].disconnected, true);
} finally { delete globalThis.DOMParser; delete globalThis.IntersectionObserver; }
});

test("Steam descriptions reject untrusted image sources", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const target = document.createElement("div");
try {
for (const src of ["http://cdn.steamstatic.com/a.gif", "https://steamstatic.com.evil.test/a.png", "https://evilsteamstatic.com/a.png", "https://user@steamstatic.com/a.png", "https://steamstatic.com:444/a.png", "https://evil.test/a.png", "file:///tmp/a.png", "data:image/svg+xml,test", "javascript:alert(1)", "//cdn.steamstatic.com/a.png", "/image.png", ""]) {
renderSteamDescription(target, `<p>Safe text<img src="${src}"></p>`);
assert.equal(target.querySelector("img"), null, src);
assert.equal(target.textContent, "Safe text");
}
} finally { delete globalThis.DOMParser; }
});

test("Steam descriptions retain PEAK video animations and codec fallbacks", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const target = document.createElement("div");
try {
const base = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3527290/extras/e82d11d9221b2a65adeb68d8cdbc3bd2";
renderSteamDescription(target, `<video autoplay muted loop playsinline poster="${base}.poster.avif" width=600 height=170 onplay="alert(1)" style="position:fixed"><source src="${base}.webm" type="video/webm; codecs=vp9"><source src="${base}.mp4" type="video/mp4"></video>`);
const video = target.querySelector("video");
assert.ok(video, "Steam now delivers GIF-like animations as video elements");
assert.equal(video.autoplay, true);
assert.equal(video.muted, true);
assert.equal(video.defaultMuted, true);
assert.equal(video.loop, true);
assert.equal(video.playsInline, true);
assert.equal(video.controls, false);
assert.equal(video.poster, `${base}.poster.avif`);
assert.equal(video.width, 600);
assert.equal(video.height, 170);
assert.deepEqual([...video.querySelectorAll("source")].map(source => [source.src, source.type]), [[`${base}.webm`, "video/webm; codecs=vp9"], [`${base}.mp4`, "video/mp4"]]);
assert.equal(target.querySelector("[onplay],[style]"), null);
} finally { delete globalThis.DOMParser; }
});

test("Steam video animations validate every source and poster", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const target = document.createElement("div");
try {
const safe = "https://shared.fastly.steamstatic.com/animation.webm";
renderSteamDescription(target, `<video src="https://evil.test/a.mp4" poster="file:///tmp/a.png"><source src="https://steamstatic.com.evil.test/a.webm"><source src="${safe}" type="video/webm" onerror="alert(1)"></video>`);
const video = target.querySelector("video");
assert.ok(video);
assert.equal(video.hasAttribute("src"), false);
assert.equal(video.hasAttribute("poster"), false);
assert.equal(video.querySelectorAll("source").length, 1);
assert.equal(video.querySelector("source").src, safe);
assert.equal(target.querySelector("[onerror]"), null);
renderSteamDescription(target, '<video src="https://evil.test/a.mp4"><source src="javascript:alert(1)"></video><p>Safe text</p>');
assert.equal(target.querySelector("video,source"), null);
assert.equal(target.textContent, "Safe text");
renderSteamDescription(target, `<video src="${safe}"></video>`);
assert.equal(target.querySelector("video").src, safe);
} finally { delete globalThis.DOMParser; }
});

test("Steam video animations respect reduced motion", async () => {
const { load } = createModuleLoader({});
const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts"));
globalThis.DOMParser = dom.window.DOMParser;
const previousMatchMedia = globalThis.matchMedia;
globalThis.matchMedia = () => ({ matches: true });
try {
const target = document.createElement("div");
renderSteamDescription(target, '<video autoplay src="https://shared.fastly.steamstatic.com/animation.mp4"></video>');
const video = target.querySelector("video");
assert.ok(video);
assert.equal(video.autoplay, false);
assert.equal(video.controls, false);
} finally { globalThis.matchMedia = previousMatchMedia; delete globalThis.DOMParser; }
});

test("library removal stops at the first failed game and keeps the library", async () => {
const calls = [];
globalThis.removalFixture = { calls, fail: "b", libraries: [] };
Expand Down
2 changes: 1 addition & 1 deletion scripts/test_module_loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function createModuleLoader(mocks) {
const modules = new Map();

async function resolveImports(code, path) {
for (const match of [...code.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)]) {
for (const match of [...code.matchAll(/(?:from\s*|import\s*|import\(\s*)["']([^"']+)["']/g)]) {
const specifier = match[1];
const resolved = specifier.startsWith(".") ? new URL(specifier, `file:///${path}`).pathname.slice(1) : null;
const url = resolved === null
Expand Down
Loading
Loading