Ver Fonte

Remove Playwright E2E test suite

Delete the entire `test/e2e/playwright` harness, including the Node runner, shared helpers, package metadata, and all Cine Studio and full-stack system spec files. This cleans out the legacy browser E2E setup that is no longer being maintained or used.
Toby Chui há 15 horas atrás
pai
commit
c64d3deb05

+ 0 - 4
test/e2e/playwright/.gitignore

@@ -1,4 +0,0 @@
-node_modules/
-package-lock.json
-test-results/
-*.png

+ 0 - 57
test/e2e/playwright/README.md

@@ -1,57 +0,0 @@
-# Cine Studio — end-to-end tests
-
-Browser-driven Playwright tests for the **Cine Studio** WebApp
-(`src/web/Cine Studio`). They drive the real app in headless Chromium,
-generating their own media in-page (canvas → MediaRecorder WebM, synthesized
-WAV) and asserting on actual rendered pixels, so they exercise the full
-compositor/playback/export pipeline rather than mocking it.
-
-These tests are front-end only. They do **not** build or run any Go code and
-live outside `src/` so they never interfere with the Go module.
-
-## Layout
-
-```
-test/e2e/playwright/
-├── package.json          Playwright dependency + `npm test`
-├── run.js                orchestrator: serves src/web, runs each spec
-├── lib/
-│   ├── static-server.js  minimal static server for the ArozOS web root
-│   └── harness.js        browser launch, app navigation, ok()/fail()
-└── specs/
-    ├── functional.js     media probe, playback, edit, round-trip, WebM export
-    ├── features.js       effects, titles, transitions, elements, filters
-    ├── interaction.js    preview drag/resize, auto tracks, .pxs/.asproj import
-    └── editing.js        multi-select, copy/paste, speed, JKL, markers, autosave…
-```
-
-## Running locally
-
-```bash
-cd test/e2e/playwright
-npm install
-npx playwright install chromium
-npm test
-```
-
-`run.js` starts a static server for `src/web` (default port 8123) and runs
-every spec in `specs/` in its own process, exiting non-zero if any fails.
-
-### Useful env vars
-
-| Var | Purpose |
-| --- | --- |
-| `CS_BASE_URL` | Point a spec at an already-running server instead of starting one |
-| `WEB_PORT` | Port for the built-in static server (default `8123`) |
-| `PW_CHROMIUM_PATH` | Use a preinstalled Chromium binary instead of Playwright's own |
-
-Run a single spec against the running server:
-
-```bash
-CS_BASE_URL=http://127.0.0.1:8123 node specs/functional.js
-```
-
-## CI
-
-`.github/workflows/e2e-playwright.yml` runs the suite on pushes and pull
-requests that touch the app or this harness.

+ 0 - 184
test/e2e/playwright/lib/arozos-server.js

@@ -1,184 +0,0 @@
-/*
-    ArozOS test-instance launcher for the full-stack E2E specs.
-
-    Boots a disposable, real ArozOS server (the Go binary built from src/)
-    inside an isolated instance folder, so specs can exercise genuine
-    authentication, desktop, file system and admin APIs without touching
-    the developer's own runtime data under src/.
-
-    Instance layout (recreated from scratch on every start):
-
-        .instance/
-        ├── web        symlink to ../../../src/web (static assets, read-only)
-        ├── system     private copy of src/system (ao.db + runtime state)
-        ├── files/     user home directories (created by the server)
-        ├── tmp/       scratch space (created by the server)
-        └── server.log combined stdout/stderr of the server process
-
-    Because the instance is wiped each start, the server always boots in
-    the zero-user state and bootstrapAdmin() creates a deterministic
-    administrator account through the same public endpoint the first-boot
-    wizard (user.html) posts to.
-
-    Env:
-      AROZOS_BIN   path to a prebuilt arozos binary (default: src/arozos,
-                   built automatically with `go build` when missing)
-*/
-"use strict";
-
-const path = require("path");
-const fs = require("fs");
-const { spawn, spawnSync } = require("child_process");
-
-const REPO_ROOT = path.resolve(__dirname, "../../../..");
-const SRC_DIR = path.join(REPO_ROOT, "src");
-const WEB_ROOT = path.join(SRC_DIR, "web");
-const SYSTEM_TEMPLATE = path.join(SRC_DIR, "system");
-const INSTANCE_DIR = path.resolve(__dirname, "../.instance");
-
-const DEFAULT_PORT = 8126;
-const ADMIN_USER = "admin";
-const ADMIN_PASS = "e2e-Admin-Passw0rd";
-
-function binaryName() {
-    return process.platform === "win32" ? "arozos.exe" : "arozos";
-}
-
-// Locate the server binary, building it with `go build` when missing.
-function resolveBinary() {
-    if (process.env.AROZOS_BIN && fs.existsSync(process.env.AROZOS_BIN)) {
-        return path.resolve(process.env.AROZOS_BIN);
-    }
-    const builtBin = path.join(SRC_DIR, binaryName());
-    if (fs.existsSync(builtBin)) {
-        return builtBin;
-    }
-    console.log("  arozos binary not found, building it (go build)...");
-    const res = spawnSync("go", ["build", "-o", binaryName(), "."], {
-        cwd: SRC_DIR,
-        stdio: "inherit"
-    });
-    if (res.status !== 0 || !fs.existsSync(builtBin)) {
-        throw new Error("Failed to build the arozos binary. Install Go or set AROZOS_BIN.");
-    }
-    return builtBin;
-}
-
-// Wipe and recreate the isolated instance directory.
-function prepareInstanceDir() {
-    fs.rmSync(INSTANCE_DIR, { recursive: true, force: true });
-    fs.mkdirSync(INSTANCE_DIR, { recursive: true });
-    fs.symlinkSync(WEB_ROOT, path.join(INSTANCE_DIR, "web"), "dir");
-    fs.cpSync(SYSTEM_TEMPLATE, path.join(INSTANCE_DIR, "system"), { recursive: true });
-}
-
-function sleep(ms) {
-    return new Promise(function (resolve) { setTimeout(resolve, ms); });
-}
-
-async function waitUntilReady(baseURL, timeoutMs) {
-    const deadline = Date.now() + timeoutMs;
-    let lastError = null;
-    while (Date.now() < deadline) {
-        try {
-            const res = await fetch(baseURL + "/system/auth/checkLogin");
-            if (res.ok) { return; }
-            lastError = new Error("HTTP " + res.status);
-        } catch (e) {
-            lastError = e;
-        }
-        await sleep(250);
-    }
-    throw new Error("ArozOS server did not become ready in time: " + (lastError ? lastError.message : "unknown"));
-}
-
-// Create the first (admin) account on a freshly wiped instance.
-async function bootstrapAdmin(baseURL) {
-    const res = await fetch(baseURL + "/system/auth/register", {
-        method: "POST",
-        headers: { "Content-Type": "application/x-www-form-urlencoded" },
-        body: new URLSearchParams({
-            username: ADMIN_USER,
-            password: ADMIN_PASS,
-            group: "administrator"
-        })
-    });
-    const text = (await res.text()).trim().toLowerCase();
-    if (text.indexOf("ok") === -1) {
-        throw new Error("Admin bootstrap failed: " + text);
-    }
-}
-
-/*
-    Start a disposable ArozOS server.
-    Returns { baseURL, admin: {username, password}, stop() }.
-*/
-async function start(options) {
-    options = options || {};
-    const port = options.port || Number(process.env.AROZ_PORT) || DEFAULT_PORT;
-    const binary = resolveBinary();
-    prepareInstanceDir();
-
-    const logStream = fs.createWriteStream(path.join(INSTANCE_DIR, "server.log"));
-    const args = [
-        "-port", String(port),
-        "-hostname", "E2E ArozOS",
-        // Keep the test instance quiet and self-contained: no LAN discovery
-        // broadcasts, no hardware/power hooks, no package auto-install and
-        // no child subservice processes.
-        "-allow_mdns=false",
-        "-allow_ssdp=false",
-        "-allow_upnp=false",
-        "-allow_iot=false",
-        "-disable_subservice",
-        "-enable_hwman=false",
-        "-enable_pwman=false",
-        "-allow_pkg_install=false",
-        "-enable_docker=false",
-        "-arozcast_turn=false"
-    ];
-    const proc = spawn(binary, args, {
-        cwd: INSTANCE_DIR,
-        stdio: ["ignore", "pipe", "pipe"]
-    });
-    proc.stdout.pipe(logStream);
-    proc.stderr.pipe(logStream);
-
-    let exited = false;
-    proc.on("exit", function () { exited = true; });
-
-    const baseURL = "http://127.0.0.1:" + port;
-    try {
-        await waitUntilReady(baseURL, 120000);
-        await bootstrapAdmin(baseURL);
-    } catch (e) {
-        proc.kill("SIGKILL");
-        throw e;
-    }
-
-    function stop() {
-        return new Promise(function (resolve) {
-            if (exited) { return resolve(); }
-            proc.on("exit", function () { resolve(); });
-            proc.kill("SIGTERM");
-            // Escalate if the server ignores SIGTERM.
-            setTimeout(function () {
-                if (!exited) { proc.kill("SIGKILL"); }
-            }, 8000).unref();
-        });
-    }
-
-    return {
-        baseURL: baseURL,
-        admin: { username: ADMIN_USER, password: ADMIN_PASS },
-        instanceDir: INSTANCE_DIR,
-        stop: stop
-    };
-}
-
-module.exports = {
-    start,
-    ADMIN_USER,
-    ADMIN_PASS,
-    INSTANCE_DIR
-};

+ 0 - 63
test/e2e/playwright/lib/harness.js

@@ -1,63 +0,0 @@
-/*
-    Shared harness for the Cine Studio E2E specs.
-
-    Each spec is a standalone runnable Node script (so it works both via
-    `node run.js` and directly, e.g. `CS_BASE_URL=... node specs/functional.js`).
-    The harness centralizes browser launch, app navigation and the
-    pass/fail reporting helpers.
-
-    Env:
-      CS_BASE_URL       base URL the app is served from (default :8123)
-      PW_CHROMIUM_PATH  optional explicit Chromium binary; otherwise
-                        Playwright resolves its own installed browser
-*/
-"use strict";
-
-const { chromium } = require("playwright");
-
-const BASE = process.env.CS_BASE_URL || "http://127.0.0.1:8123";
-const APP_URL = BASE + "/Cine%20Studio/index.html";
-
-function ok(msg) { console.log("  PASS: " + msg); }
-
-function fail(msg) {
-    console.error("  FAIL: " + msg);
-    process.exit(1);
-}
-
-function launch() {
-    return chromium.launch({
-        // Undefined lets Playwright resolve its installed browser (CI);
-        // set PW_CHROMIUM_PATH to point at a preinstalled binary locally.
-        executablePath: process.env.PW_CHROMIUM_PATH || undefined,
-        args: ["--autoplay-policy=no-user-gesture-required"]
-    });
-}
-
-// Open the app and wait until the editor global (CS) has booted.
-async function openApp(browser, viewport) {
-    const page = await browser.newPage({ viewport: viewport || { width: 1280, height: 853 } });
-    page.on("pageerror", function (e) { console.log("  [pageerror] " + e.message); });
-    await page.goto(APP_URL, { waitUntil: "networkidle" });
-    await page.waitForFunction(function () { return window.CS && CS.project; });
-    return page;
-}
-
-// Wrap a spec body: run it, report a banner, exit non-zero on throw.
-function run(name, body) {
-    (async function () {
-        const browser = await launch();
-        try {
-            const page = await openApp(browser);
-            await body(page, browser);
-            console.log("ALL " + name + " TESTS PASSED");
-        } finally {
-            await browser.close();
-        }
-    })().catch(function (e) {
-        console.error(e);
-        process.exit(1);
-    });
-}
-
-module.exports = { BASE, APP_URL, ok, fail, launch, openApp, run };

+ 0 - 83
test/e2e/playwright/lib/static-server.js

@@ -1,83 +0,0 @@
-/*
-    Minimal static file server for the Cine Studio E2E tests.
-
-    Serves the ArozOS web root (src/web) so the app and its shared
-    scripts (../script/*) load exactly as they do in production. The
-    tests seed their own media as in-memory blobs and stub the ArozOS
-    backend, so server-side AGI endpoints (/system/*, /media) are not
-    needed here and simply 404 - which the app already handles.
-*/
-"use strict";
-
-const http = require("http");
-const fs = require("fs");
-const path = require("path");
-
-const MIME = {
-    ".html": "text/html; charset=utf-8",
-    ".js": "text/javascript; charset=utf-8",
-    ".css": "text/css; charset=utf-8",
-    ".json": "application/json; charset=utf-8",
-    ".png": "image/png",
-    ".jpg": "image/jpeg",
-    ".jpeg": "image/jpeg",
-    ".gif": "image/gif",
-    ".webp": "image/webp",
-    ".svg": "image/svg+xml",
-    ".ico": "image/x-icon",
-    ".woff": "font/woff",
-    ".woff2": "font/woff2",
-    ".ttf": "font/ttf",
-    ".map": "application/json; charset=utf-8"
-};
-
-// Create (but do not start) a server rooted at webRoot.
-function createServer(webRoot) {
-    const root = path.resolve(webRoot);
-    return http.createServer(function (req, res) {
-        let urlPath;
-        try {
-            urlPath = decodeURIComponent(req.url.split("?")[0].split("#")[0]);
-        } catch (e) {
-            res.writeHead(400);
-            res.end("bad request");
-            return;
-        }
-        if (urlPath.endsWith("/")) { urlPath += "index.html"; }
-
-        // Resolve within the web root, rejecting path traversal.
-        const target = path.join(root, urlPath);
-        if (target !== root && !target.startsWith(root + path.sep)) {
-            res.writeHead(403);
-            res.end("forbidden");
-            return;
-        }
-
-        fs.stat(target, function (err, stat) {
-            if (err || !stat.isFile()) {
-                res.writeHead(404);
-                res.end("not found");
-                return;
-            }
-            res.writeHead(200, {
-                "Content-Type": MIME[path.extname(target).toLowerCase()] || "application/octet-stream",
-                "Cache-Control": "no-store"
-            });
-            fs.createReadStream(target).pipe(res);
-        });
-    });
-}
-
-// Start a server and resolve with { server, port, baseURL }.
-function start(webRoot, port) {
-    return new Promise(function (resolve, reject) {
-        const server = createServer(webRoot);
-        server.on("error", reject);
-        server.listen(port || 0, "127.0.0.1", function () {
-            const actual = server.address().port;
-            resolve({ server: server, port: actual, baseURL: "http://127.0.0.1:" + actual });
-        });
-    });
-}
-
-module.exports = { createServer, start };

+ 0 - 149
test/e2e/playwright/lib/system-harness.js

@@ -1,149 +0,0 @@
-/*
-    Shared harness for the full-stack (real server) E2E specs.
-
-    Each spec in specs-system/ is a standalone runnable Node script. When
-    executed through run.js a shared server is already up and its address
-    arrives via AROZ_BASE_URL; when a spec is run directly with no
-    AROZ_BASE_URL, the harness boots its own disposable server so
-    `node specs-system/010-auth.js` still works on its own.
-
-    Env:
-      AROZ_BASE_URL     base URL of an already-running test instance
-      AROZ_ADMIN_USER   admin username of that instance (default "admin")
-      AROZ_ADMIN_PASS   admin password of that instance
-      PW_CHROMIUM_PATH  optional explicit Chromium binary
-*/
-"use strict";
-
-const { chromium } = require("playwright");
-const arozosServer = require("./arozos-server");
-
-function ok(msg) { console.log("  PASS: " + msg); }
-
-function fail(msg) {
-    console.error("  FAIL: " + msg);
-    process.exit(1);
-}
-
-function launch() {
-    return chromium.launch({
-        executablePath: process.env.PW_CHROMIUM_PATH || undefined,
-        args: ["--autoplay-policy=no-user-gesture-required"]
-    });
-}
-
-async function newPage(browser) {
-    const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
-    const page = await context.newPage();
-    page.on("pageerror", function (e) { console.log("  [pageerror] " + e.message); });
-    return page;
-}
-
-// Log in through the real login form UI. Resolves once the browser has
-// been redirected away from login.html.
-async function loginViaForm(page, baseURL, username, password) {
-    await page.goto(baseURL + "/login.html", { waitUntil: "domcontentloaded" });
-    await page.fill("#username", username);
-    await page.fill("#magic", password);
-    await Promise.all([
-        page.waitForURL(function (url) { return url.pathname.indexOf("login.html") === -1; }, { timeout: 15000 }),
-        page.click("#loginbtn")
-    ]);
-}
-
-// Log in via the auth API using the page's cookie jar (fast path for
-// specs that are not about the login UI itself).
-async function loginViaAPI(page, baseURL, username, password) {
-    const res = await page.request.post(baseURL + "/system/auth/login", {
-        form: { username: username, password: password, rmbme: "false" }
-    });
-    const body = (await res.text()).trim();
-    const authed = (await (await page.request.get(baseURL + "/system/auth/checkLogin")).text()).trim();
-    if (authed !== "true") {
-        throw new Error("API login failed for " + username + ": " + body);
-    }
-}
-
-async function logout(page, baseURL) {
-    await page.request.get(baseURL + "/system/auth/logout");
-}
-
-async function isLoggedIn(page, baseURL) {
-    const res = await page.request.get(baseURL + "/system/auth/checkLogin");
-    return (await res.text()).trim() === "true";
-}
-
-// GET a JSON endpoint with the page's session cookies.
-async function getJSON(page, url) {
-    const res = await page.request.get(url);
-    const text = await res.text();
-    try {
-        return JSON.parse(text);
-    } catch (e) {
-        throw new Error("Expected JSON from " + url + " but got: " + text.slice(0, 200));
-    }
-}
-
-// POST form parameters, returning the raw response text.
-async function postForm(page, url, form) {
-    const res = await page.request.post(url, { form: form });
-    return (await res.text()).trim();
-}
-
-// Fetch a fresh CSRF token for endpoints that require one (fileOpr, newItem).
-async function csrfToken(page, baseURL) {
-    const token = await getJSON(page, baseURL + "/system/csrf/new");
-    if (typeof token !== "string" || !token.length) {
-        throw new Error("Could not obtain CSRF token");
-    }
-    return token;
-}
-
-/*
-    Wrap a spec body. Boots a private server when AROZ_BASE_URL is not
-    provided, launches the browser, and reports the pass/fail banner.
-    body receives ({ browser, baseURL, admin }).
-*/
-function run(name, body) {
-    (async function () {
-        let server = null;
-        let baseURL = process.env.AROZ_BASE_URL;
-        let admin = {
-            username: process.env.AROZ_ADMIN_USER || arozosServer.ADMIN_USER,
-            password: process.env.AROZ_ADMIN_PASS || arozosServer.ADMIN_PASS
-        };
-        if (!baseURL) {
-            console.log("  AROZ_BASE_URL not set - booting a private ArozOS test instance...");
-            server = await arozosServer.start({});
-            baseURL = server.baseURL;
-            admin = server.admin;
-        }
-
-        const browser = await launch();
-        try {
-            await body({ browser: browser, baseURL: baseURL, admin: admin });
-            console.log("ALL " + name + " TESTS PASSED");
-        } finally {
-            await browser.close();
-            if (server) { await server.stop(); }
-        }
-    })().catch(function (e) {
-        console.error(e);
-        process.exit(1);
-    });
-}
-
-module.exports = {
-    ok,
-    fail,
-    launch,
-    newPage,
-    loginViaForm,
-    loginViaAPI,
-    logout,
-    isLoggedIn,
-    getJSON,
-    postForm,
-    csrfToken,
-    run
-};

+ 0 - 12
test/e2e/playwright/package.json

@@ -1,12 +0,0 @@
-{
-  "name": "cinestudio-e2e",
-  "version": "1.0.0",
-  "private": true,
-  "description": "Playwright end-to-end tests for the Cine Studio ArozOS WebApp",
-  "scripts": {
-    "test": "node run.js"
-  },
-  "devDependencies": {
-    "playwright": "1.56.1"
-  }
-}

+ 0 - 64
test/e2e/playwright/run.js

@@ -1,64 +0,0 @@
-/*
-    Cine Studio E2E runner.
-
-    Starts a static server for the ArozOS web root, then runs each spec
-    in its own Node process against it. Exits non-zero if any spec fails,
-    so CI turns red on the first regression. The banner from each spec's
-    final assertion is printed inline.
-*/
-"use strict";
-
-const path = require("path");
-const fs = require("fs");
-const { spawn } = require("child_process");
-const staticServer = require("./lib/static-server");
-
-const WEB_ROOT = path.resolve(__dirname, "../../../src/web");
-const SPECS_DIR = path.join(__dirname, "specs");
-
-function runSpec(specPath, baseURL) {
-    return new Promise(function (resolve) {
-        const child = spawn(process.execPath, [specPath], {
-            stdio: "inherit",
-            env: Object.assign({}, process.env, { CS_BASE_URL: baseURL })
-        });
-        child.on("exit", function (code) { resolve(code || 0); });
-    });
-}
-
-(async function () {
-    if (!fs.existsSync(path.join(WEB_ROOT, "Cine Studio", "index.html"))) {
-        console.error("Cannot find Cine Studio web app under " + WEB_ROOT);
-        process.exit(1);
-    }
-
-    const specs = fs.readdirSync(SPECS_DIR)
-        .filter(function (f) { return f.endsWith(".js"); })
-        .sort();
-    if (!specs.length) {
-        console.error("No specs found in " + SPECS_DIR);
-        process.exit(1);
-    }
-
-    const { server, baseURL } = await staticServer.start(WEB_ROOT, Number(process.env.WEB_PORT) || 8123);
-    console.log("Serving " + WEB_ROOT + " at " + baseURL + "\n");
-
-    let failures = 0;
-    for (const spec of specs) {
-        console.log("── " + spec + " ──────────────────────────────────");
-        const code = await runSpec(path.join(SPECS_DIR, spec), baseURL);
-        if (code !== 0) { failures++; console.log("  spec exited with code " + code); }
-        console.log("");
-    }
-
-    server.close();
-
-    if (failures) {
-        console.error(failures + " of " + specs.length + " spec file(s) failed.");
-        process.exit(1);
-    }
-    console.log("All " + specs.length + " spec file(s) passed.");
-})().catch(function (e) {
-    console.error(e);
-    process.exit(1);
-});

+ 0 - 94
test/e2e/playwright/specs-system/010-auth.js

@@ -1,94 +0,0 @@
-/*
-    Critical path: sign in / sign out.
-
-    Drives the real login.html form and the auth API against a live
-    ArozOS server: page render, credential rejection, successful login,
-    session persistence, auth-gated redirects and logout.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
-
-h.run("AUTH", async function (env) {
-    const base = env.baseURL;
-    const admin = env.admin;
-
-    // ── 1. Login page renders with the expected form controls ──
-    let page = await h.newPage(env.browser);
-    await page.goto(base + "/login.html", { waitUntil: "domcontentloaded" });
-    if (!(await page.isVisible("#username"))) h.fail("login page: username field not visible");
-    if (!(await page.isVisible("#magic"))) h.fail("login page: password field not visible");
-    if (!(await page.isVisible("#loginbtn"))) h.fail("login page: sign-in button not visible");
-    h.ok("login page renders username/password fields and sign-in button");
-
-    // ── 2. Auth-gated pages redirect anonymous visitors to login ──
-    await page.goto(base + "/desktop.html", { waitUntil: "domcontentloaded" });
-    if (page.url().indexOf("login.html") === -1) {
-        h.fail("anonymous visit to desktop.html was not redirected to login.html (got " + page.url() + ")");
-    }
-    h.ok("anonymous visit to desktop.html redirects to the login page");
-
-    // ── 3. Bogus credentials are rejected ──
-    await page.goto(base + "/login.html", { waitUntil: "domcontentloaded" });
-    await page.fill("#username", "no-such-user");
-    await page.fill("#magic", "definitely-wrong");
-    await page.click("#loginbtn");
-    await sleep(1500); // give the form's ajax round-trip time to finish
-    if (page.url().indexOf("login.html") === -1) h.fail("bogus credentials left the login page");
-    if (await h.isLoggedIn(page, base)) h.fail("bogus credentials produced a session");
-    h.ok("bogus credentials are rejected and no session is created");
-
-    // ── 4. Real login through the form lands on the desktop ──
-    await h.loginViaForm(page, base, admin.username, admin.password);
-    if (!(await h.isLoggedIn(page, base))) h.fail("form login did not create a session");
-    await page.goto(base + "/", { waitUntil: "domcontentloaded" });
-    if (page.url().indexOf("desktop.html") === -1) {
-        h.fail("logged-in visit to / did not land on desktop.html (got " + page.url() + ")");
-    }
-    h.ok("form login succeeds and / lands on desktop.html");
-
-    // ── 5. Session persists across pages in the same browser context ──
-    const page2 = await page.context().newPage();
-    await page2.goto(base + "/", { waitUntil: "domcontentloaded" });
-    if (page2.url().indexOf("desktop.html") === -1) h.fail("session did not persist to a second page");
-    await page2.close();
-    h.ok("session persists across pages in the same context");
-
-    // ── 6. Logout kills the session; auth-gated pages redirect again ──
-    // Leave the desktop first so its background pollers cannot race the
-    // logout and re-write the session cookie in the browser's jar.
-    await page.goto("about:blank");
-    await h.logout(page, base);
-    if (await h.isLoggedIn(page, base)) h.fail("logout left the session alive");
-    // Query param busts the browser HTTP cache - desktop.html was cached
-    // during the logged-in visit and would otherwise never hit the server.
-    await page.goto(base + "/desktop.html?after_logout=1", { waitUntil: "domcontentloaded" });
-    if (page.url().indexOf("login.html") === -1) h.fail("post-logout visit to desktop.html did not land on login.html");
-    h.ok("logout destroys the session and the desktop redirects to login again");
-    await page.close();
-
-    // ── 7. Wrong password for a real account (fresh context) ──
-    page = await h.newPage(env.browser);
-    const res = await page.request.post(base + "/system/auth/login", {
-        form: { username: admin.username, password: "wrong-password", rmbme: "false" }
-    });
-    const body = (await res.text()).trim();
-    if (await h.isLoggedIn(page, base)) h.fail("wrong password for real account produced a session");
-    if (body.indexOf("error") === -1) h.fail("wrong-password login did not return an error: " + body);
-    h.ok("wrong password for a real account is rejected");
-
-    // The exponential login-delay counter now blocks this user/IP pair for
-    // ~2s; wait it out, then confirm the correct password works again.
-    await sleep(3000);
-    await h.loginViaAPI(page, base, admin.username, admin.password);
-    h.ok("correct password logs in again after the failed-attempt delay");
-
-    // ── 8. checkLogin reflects the API session state ──
-    if (!(await h.isLoggedIn(page, base))) h.fail("checkLogin false after API login");
-    await h.logout(page, base);
-    if (await h.isLoggedIn(page, base)) h.fail("checkLogin true after logout");
-    h.ok("checkLogin correctly tracks login and logout");
-    await page.close();
-});

+ 0 - 78
test/e2e/playwright/specs-system/020-desktop.js

@@ -1,78 +0,0 @@
-/*
-    Critical path: desktop shell.
-
-    Loads the real desktop.html as an authenticated user and checks the
-    shell furniture (wallpaper layer, task bar), the start/list menu with
-    its module launcher entries, the quick-access panel user identity,
-    the desktop APIs and the sign-out button.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-h.run("DESKTOP", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // ── 1. Desktop shell loads ──
-    await page.goto(base + "/desktop.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#bgwrapper", { state: "visible", timeout: 20000 });
-    await page.waitForSelector("#navimenu", { state: "visible", timeout: 20000 });
-    h.ok("desktop shell renders wallpaper layer and task bar");
-
-    // ── 2. Desktop APIs answer for the logged-in user ──
-    const userInfo = await h.getJSON(page, base + "/system/desktop/user");
-    const userInfoStr = JSON.stringify(userInfo);
-    if (userInfoStr.indexOf(env.admin.username) === -1) {
-        h.fail("/system/desktop/user does not mention the logged-in user: " + userInfoStr);
-    }
-    h.ok("/system/desktop/user identifies the logged-in user");
-
-    const hostInfo = await h.getJSON(page, base + "/system/desktop/host");
-    if (JSON.stringify(hostInfo).indexOf("E2E ArozOS") === -1) {
-        h.fail("/system/desktop/host does not carry the configured hostname: " + JSON.stringify(hostInfo));
-    }
-    h.ok("/system/desktop/host reports the configured hostname");
-
-    // ── 3. Module list includes the critical built-ins ──
-    const modules = await h.getJSON(page, base + "/system/modules/list");
-    const moduleNames = modules.map(function (m) { return m.Name; });
-    ["System Setting", "File Manager", "Desktop"].forEach(function (name) {
-        if (moduleNames.indexOf(name) === -1) {
-            h.fail("module list missing '" + name + "'. Got: " + moduleNames.join(", "));
-        }
-    });
-    h.ok("module list includes Desktop, File Manager and System Setting");
-
-    // ── 4. Start (list) menu opens and offers module entries ──
-    await page.click('#navimenu div.item[onclick*="toggleListMenu"]');
-    await page.waitForSelector("#listMenu", { state: "visible", timeout: 10000 });
-    await page.waitForFunction(function () {
-        var holder = document.getElementById("listMenuItem");
-        return holder && holder.children.length > 0;
-    }, { timeout: 15000 });
-    if (!(await page.isVisible("#searchBar"))) h.fail("start menu search bar not visible");
-    h.ok("start menu opens with module entries and a search bar");
-    // Close it again so it does not overlap the quick access panel.
-    await page.click('#navimenu div.item[onclick*="toggleListMenu"]');
-
-    // ── 5. Quick access panel shows the user identity ──
-    await page.click('#navimenu div.item[onclick*="showToolPanel"]');
-    await page.waitForSelector("#quickAccessPanel", { state: "visible", timeout: 10000 });
-    await page.waitForFunction(function (uname) {
-        var el = document.getElementById("username");
-        return el && el.textContent.trim().toLowerCase().indexOf(uname) !== -1;
-    }, env.admin.username.toLowerCase(), { timeout: 10000 });
-    h.ok("quick access panel shows the logged-in username");
-
-    // ── 6. Sign out from the desktop UI ──
-    // The desktop logout() asks with a native confirm() dialog first.
-    page.on("dialog", function (dialog) { dialog.accept(); });
-    await page.click("#logoutBtn");
-    await page.waitForURL(function (url) { return url.pathname.indexOf("desktop.html") === -1; }, { timeout: 20000 });
-    if (await h.isLoggedIn(page, base)) h.fail("desktop logout button left the session alive");
-    h.ok("desktop sign-out button ends the session and leaves the desktop");
-
-    await page.close();
-});

+ 0 - 163
test/e2e/playwright/specs-system/021-desktop-api.js

@@ -1,163 +0,0 @@
-/*
-    Desktop: backend API surface.
-
-    Exercises every endpoint registered by DesktopInit in src/desktop.go,
-    driving the exact request shapes the desktop.html front end uses:
-
-      /system/desktop/host            host / device details
-      /system/desktop/user            self info (quota, groups, admin),
-                                       another user's public info, noicon
-      /system/desktop/theme           list wallpapers, set + read back
-      /system/desktop/preference      set / get / remove a preference key
-      /system/desktop/files           icon location set / get / delete
-      /system/desktop/createShortcut  create a .shortcut on the desktop
-      /system/desktop/listDesktop     list desktop objects + shortcut meta
-      /system/desktop/opr/renameShortcut  rename it, and reject off-desktop
-
-    All backend, so this is the deterministic backbone of the desktop
-    coverage; the UI specs (022, 023) drive the same features visually.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const OTHER_USER = "e2edeskother";
-const OTHER_PASS = "e2e-Other-Pass1";
-const SC_NAME = "e2e-shortcut";
-const SC_FILE = "user:/Desktop/" + SC_NAME + ".shortcut";
-
-h.run("DESKTOP-API", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // ── 1. /host reports device details ──
-    const host = await h.getJSON(page, base + "/system/desktop/host");
-    ["Hostname", "DeviceUUID", "BuildVersion", "InternalVersion"].forEach(function (k) {
-        if (!(k in host)) h.fail("/system/desktop/host missing field " + k);
-    });
-    if (host.Hostname.indexOf("E2E ArozOS") === -1) {
-        h.fail("/system/desktop/host wrong hostname: " + host.Hostname);
-    }
-    h.ok("/host returns hostname, device UUID and version fields");
-
-    // ── 2. /user reports the signed-in user's profile ──
-    const me = await h.getJSON(page, base + "/system/desktop/user");
-    if (me.Username !== env.admin.username) h.fail("/user wrong username: " + me.Username);
-    if (me.IsAdmin !== true) h.fail("/user should mark the admin account as admin");
-    if (!Array.isArray(me.UserGroups) || me.UserGroups.indexOf("administrator") === -1) {
-        h.fail("/user should list the administrator group: " + JSON.stringify(me.UserGroups));
-    }
-    if (typeof me.StorageQuotaTotal !== "number") h.fail("/user missing StorageQuotaTotal");
-    h.ok("/user reports username, admin flag, groups and storage quota");
-
-    // noicon=true (POST param) strips the (potentially large) icon payload.
-    const meNoIcon = JSON.parse(await h.postForm(page, base + "/system/desktop/user", { noicon: "true" }));
-    if (meNoIcon.UserIcon !== "") h.fail("/user noicon=true should return an empty UserIcon");
-    h.ok("/user with noicon=true omits the icon payload");
-
-    // Another user's *public* info via ?target= (create one, query, remove).
-    let resp = await h.postForm(page, base + "/system/auth/register", {
-        username: OTHER_USER, password: OTHER_PASS, group: "user"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not create second user: " + resp);
-    const other = await h.getJSON(page, base + "/system/desktop/user?target=" + encodeURIComponent(OTHER_USER));
-    if (other.Username !== OTHER_USER) h.fail("/user?target did not return the target user");
-    if (other.IsAdmin !== false) h.fail("/user?target should not mark a plain user as admin");
-    h.ok("/user?target returns another user's public info");
-    const missing = await page.request.get(base + "/system/desktop/user?target=nosuchuser____");
-    if ((await missing.text()).toLowerCase().indexOf("error") === -1) {
-        h.fail("/user?target for a missing user should error");
-    }
-    h.ok("/user?target for a non-existent user returns an error");
-
-    // ── 3. /theme lists wallpapers and round-trips the user's choice ──
-    const themes = await h.getJSON(page, base + "/system/desktop/theme");
-    if (!Array.isArray(themes) || themes.length === 0) h.fail("/theme returned no wallpaper themes");
-    if (!("Theme" in themes[0]) || !("Bglist" in themes[0])) {
-        h.fail("/theme entries missing Theme/Bglist fields: " + JSON.stringify(themes[0]));
-    }
-    h.ok("/theme lists wallpaper themes with their backgrounds");
-
-    const chosen = themes[0].Theme;
-    const setThemeResp = (await (await page.request.get(base + "/system/desktop/theme?set=" +
-        encodeURIComponent(chosen))).text()).trim();
-    if (setThemeResp.replace(/"/g, "").toLowerCase() !== "ok") h.fail("/theme?set failed: " + setThemeResp);
-    const gotTheme = (await (await page.request.get(base + "/system/desktop/theme?get=true")).text()).trim();
-    if (gotTheme.replace(/"/g, "") !== chosen) {
-        h.fail("/theme?get did not return the theme just set (" + gotTheme + " != " + chosen + ")");
-    }
-    h.ok("/theme?set then ?get round-trips the selected wallpaper theme");
-
-    // ── 4. /preference set / get / remove ──
-    const prefKey = "e2e_pref";
-    const prefVal = "value-" + Date.now();
-    resp = await h.postForm(page, base + "/system/desktop/preference", { preference: prefKey, value: prefVal });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("preference set failed: " + resp);
-    let gotPref = JSON.parse(await h.postForm(page, base + "/system/desktop/preference", { preference: prefKey }));
-    if (gotPref !== prefVal) h.fail("preference get did not return the stored value: " + gotPref);
-    h.ok("/preference stores and returns a preference value");
-    resp = await h.postForm(page, base + "/system/desktop/preference", { preference: prefKey, remove: "true" });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("preference remove failed: " + resp);
-    gotPref = JSON.parse(await h.postForm(page, base + "/system/desktop/preference", { preference: prefKey }));
-    if (gotPref !== "") h.fail("preference should be empty after removal, got: " + gotPref);
-    h.ok("/preference removes a preference key");
-
-    // ── 5. createShortcut puts a .shortcut on the desktop ──
-    resp = await h.postForm(page, base + "/system/desktop/createShortcut", {
-        stype: "module", stext: SC_NAME, spath: "Dummy/index.html", sicon: "img/system/favicon.png"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("createShortcut failed: " + resp);
-    h.ok("/createShortcut writes a shortcut file to the desktop");
-
-    // ── 6. listDesktop reports the shortcut with parsed metadata ──
-    let desktop = await h.getJSON(page, base + "/system/desktop/listDesktop");
-    let sc = desktop.filter(function (o) { return o.Filename === SC_NAME + ".shortcut"; })[0];
-    if (!sc) h.fail("listDesktop did not include the created shortcut");
-    if (!sc.IsShortcut || sc.ShortcutType !== "module" || sc.ShortcutName !== SC_NAME) {
-        h.fail("listDesktop shortcut metadata wrong: " + JSON.stringify(sc));
-    }
-    h.ok("/listDesktop returns the shortcut with IsShortcut + parsed name/type");
-
-    // ── 7. icon location set / get / delete via /files ──
-    resp = await h.postForm(page, base + "/system/desktop/files", {
-        set: SC_NAME + ".shortcut", x: "123", y: "456"
-    });
-    if (resp.replace(/"/g, "").toLowerCase() !== "ok") h.fail("/files set location failed: " + resp);
-    const loc = JSON.parse(await h.postForm(page, base + "/system/desktop/files", { get: SC_NAME + ".shortcut" }));
-    if (loc[0] !== 123 || loc[1] !== 456) h.fail("/files get returned wrong location: " + JSON.stringify(loc));
-    h.ok("/files stores and returns a desktop icon coordinate");
-    await h.postForm(page, base + "/system/desktop/files", { del: SC_NAME + ".shortcut" });
-    const locGone = JSON.parse(await h.postForm(page, base + "/system/desktop/files", { get: SC_NAME + ".shortcut" }));
-    if (locGone[0] !== -1 || locGone[1] !== -1) h.fail("/files del did not clear the location: " + JSON.stringify(locGone));
-    h.ok("/files deletes a desktop icon coordinate");
-
-    // ── 8. renameShortcut updates the shortcut's display name ──
-    const newName = "e2e-renamed";
-    resp = (await (await page.request.get(base + "/system/desktop/opr/renameShortcut?src=" +
-        encodeURIComponent(SC_FILE) + "&new=" + encodeURIComponent(newName))).text()).trim();
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("renameShortcut failed: " + resp);
-    desktop = await h.getJSON(page, base + "/system/desktop/listDesktop");
-    sc = desktop.filter(function (o) { return o.Filename === SC_NAME + ".shortcut"; })[0];
-    if (!sc || sc.ShortcutName !== newName) {
-        h.fail("renameShortcut did not update the display name: " + JSON.stringify(sc));
-    }
-    h.ok("/opr/renameShortcut updates the shortcut display name");
-
-    // Off-desktop rename must be refused.
-    const badRename = (await (await page.request.get(base + "/system/desktop/opr/renameShortcut?src=" +
-        encodeURIComponent("user:/Documents/whatever.shortcut") + "&new=x")).text()).trim();
-    if (badRename.toLowerCase().indexOf("error") === -1) {
-        h.fail("renameShortcut on a non-desktop path should be refused: " + badRename);
-    }
-    h.ok("/opr/renameShortcut refuses a path outside the desktop");
-
-    // ── Cleanup ──
-    const csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify([SC_FILE]), csrft: csrft
-    });
-    await h.postForm(page, base + "/system/users/removeUser", { username: OTHER_USER });
-
-    await page.close();
-});

+ 0 - 145
test/e2e/playwright/specs-system/022-desktop-ui.js

@@ -1,145 +0,0 @@
-/*
-    Desktop: UI shell.
-
-    Drives the real desktop.html and asserts the visible shell works in
-    every major aspect a user touches:
-
-      - wallpaper layer + task bar render
-      - the taskbar clock shows a real time
-      - the quick-access panel carries the user's identity + storage bar
-      - the start (list) menu opens, lists modules, and its search box
-        filters the module list (and reports "No Result" for gibberish)
-      - the notification bar toggles from the clock
-      - a desktop shortcut created via the API renders as a launch icon
-
-    Window management and context menus have their own spec (023).
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const SC_NAME = "e2e-ui-shortcut";
-const SC_FILE = "user:/Desktop/" + SC_NAME + ".shortcut";
-
-async function openDesktop(page, base) {
-    await page.goto(base + "/desktop.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#bgwrapper", { state: "visible", timeout: 20000 });
-    await page.waitForSelector("#navimenu", { state: "visible", timeout: 20000 });
-    // The module list backs the start menu + search; wait until it is loaded.
-    await page.waitForFunction(function () {
-        return Array.isArray(window.moduleInstalled) && window.moduleInstalled.length > 0;
-    }, { timeout: 20000 });
-}
-
-h.run("DESKTOP-UI", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // Seed a desktop shortcut so the icon layer has something deterministic.
-    let resp = await h.postForm(page, base + "/system/desktop/createShortcut", {
-        stype: "module", stext: SC_NAME, spath: "Dummy/index.html", sicon: "img/system/favicon.png"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not seed desktop shortcut: " + resp);
-
-    await openDesktop(page, base);
-    h.ok("desktop shell renders wallpaper layer and task bar");
-
-    // ── 1. Wallpaper background frame is mounted ──
-    await page.waitForFunction(function () {
-        return document.querySelectorAll("#bgwrapper .backgroundFrame").length > 0;
-    }, { timeout: 20000 });
-    h.ok("a wallpaper background frame is mounted in the desktop");
-
-    // ── 2. The taskbar clock shows a real time ──
-    await page.waitForFunction(function () {
-        var c = document.querySelector(".clock");
-        return c && /\d{1,2}:\d{2}\s*(AM|PM)/i.test(c.textContent);
-    }, { timeout: 15000 });
-    h.ok("the taskbar clock displays a formatted time");
-
-    // ── 3. Quick access panel shows identity + storage ──
-    await page.click('#navimenu div.item[onclick*="showToolPanel"]');
-    await page.waitForSelector("#quickAccessPanel", { state: "visible", timeout: 10000 });
-    await page.waitForFunction(function (uname) {
-        var el = document.getElementById("username");
-        return el && el.textContent.trim().toLowerCase().indexOf(uname) !== -1;
-    }, env.admin.username.toLowerCase(), { timeout: 10000 });
-    h.ok("quick access panel shows the logged-in username");
-    // Close the panel again.
-    await page.click('#navimenu div.item[onclick*="showToolPanel"]');
-
-    // ── 4. Start menu opens with module entries + search box ──
-    await page.click('#navimenu div.item[onclick*="toggleListMenu"]');
-    await page.waitForSelector("#listMenu", { state: "visible", timeout: 10000 });
-    await page.waitForFunction(function () {
-        var holder = document.getElementById("listMenuItem");
-        return holder && holder.children.length > 0;
-    }, { timeout: 15000 });
-    const fullCount = await page.evaluate(function () {
-        return document.getElementById("listMenuItem").children.length;
-    });
-    if (!(await page.isVisible("#searchBar"))) h.fail("start menu search bar not visible");
-    h.ok("start menu opens with " + fullCount + " module entries and a search bar");
-
-    // ── 5. Search filters the module list ──
-    await page.fill("#searchBar", "File Manager");
-    await page.press("#searchBar", "Enter");
-    await page.waitForFunction(function () {
-        var items = document.querySelectorAll("#listMenuItem .item");
-        if (items.length === 0) return false;
-        return document.getElementById("listMenuItem").textContent.indexOf("File Manager") !== -1;
-    }, { timeout: 10000 });
-    const filtered = await page.evaluate(function () {
-        return document.querySelectorAll("#listMenuItem .item").length;
-    });
-    if (filtered >= fullCount) {
-        h.fail("search did not narrow the module list (" + filtered + " vs " + fullCount + ")");
-    }
-    h.ok("start menu search narrows the list to matching modules");
-
-    // Gibberish yields an explicit "No Result".
-    await page.fill("#searchBar", "zzzznosuchmodulezzzz");
-    await page.press("#searchBar", "Enter");
-    await page.waitForFunction(function () {
-        return document.getElementById("listMenuItem").textContent.indexOf("No Result") !== -1;
-    }, { timeout: 10000 });
-    h.ok("start menu search reports 'No Result' for an unknown keyword");
-    // Close start menu.
-    await page.click('#navimenu div.item[onclick*="toggleListMenu"]');
-
-    // ── 6. Notification bar toggles from the clock ──
-    await page.click(".clock");
-    await page.waitForFunction(function () {
-        var n = document.querySelector(".notificationbar");
-        if (!n) return false;
-        var style = window.getComputedStyle(n);
-        return style.display !== "none" && parseFloat(style.opacity) > 0.5;
-    }, { timeout: 10000 });
-    h.ok("clicking the clock opens the notification bar");
-    // The notification bar lays a full-screen .cover over the desktop; close
-    // it through the same handler the cover uses so later DOM is unobscured.
-    await page.evaluate(function () { toggleNotification("hide"); });
-    await page.waitForFunction(function () {
-        var n = document.querySelector(".notificationbar");
-        return !n || window.getComputedStyle(n).display === "none" ||
-            parseFloat(window.getComputedStyle(n).opacity) < 0.5;
-    }, { timeout: 10000 });
-
-    // ── 7. The seeded shortcut renders as a desktop launch icon ──
-    await page.waitForFunction(function (name) {
-        var icons = document.querySelectorAll(".launchIcon");
-        for (var i = 0; i < icons.length; i++) {
-            if (icons[i].textContent.indexOf(name) !== -1) return true;
-        }
-        return false;
-    }, SC_NAME, { timeout: 20000 });
-    h.ok("the seeded shortcut renders as a desktop launch icon");
-
-    // ── Cleanup ──
-    const csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify([SC_FILE]), csrft: csrft
-    });
-    await page.close();
-});

+ 0 - 176
test/e2e/playwright/specs-system/023-desktop-windows.js

@@ -1,176 +0,0 @@
-/*
-    Desktop: float windows + context menus.
-
-    Covers the window-manager and right-click surfaces of the desktop:
-
-      - launching a module through openModule spawns a float window
-      - newFloatWindow creates windows with a taskbar entry
-      - focus brings a window to the front (z-index ordering)
-      - maximize / restore toggles the window's max state
-      - minimize hides the window; the close button destroys it
-      - right-clicking the wallpaper shows the desktop context menu
-      - right-clicking a desktop icon shows the icon context menu
-
-    Window internals are driven through the app's own global functions
-    and real button clicks, so the actual desktop.html logic runs.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const SC_NAME = "e2e-win-shortcut";
-const SC_FILE = "user:/Desktop/" + SC_NAME + ".shortcut";
-
-async function openDesktop(page, base) {
-    await page.goto(base + "/desktop.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#bgwrapper", { state: "visible", timeout: 20000 });
-    await page.waitForFunction(function () {
-        return typeof window.newFloatWindow === "function" &&
-            Array.isArray(window.moduleInstalled) && window.moduleInstalled.length > 0;
-    }, { timeout: 20000 });
-    await page.waitForFunction(function () {
-        return document.querySelectorAll("#bgwrapper .backgroundFrame").length > 0;
-    }, { timeout: 20000 });
-}
-
-h.run("DESKTOP-WINDOWS", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // Seed a desktop shortcut for the icon context-menu test.
-    let resp = await h.postForm(page, base + "/system/desktop/createShortcut", {
-        stype: "module", stext: SC_NAME, spath: "Dummy/index.html", sicon: "img/system/favicon.png"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not seed desktop shortcut: " + resp);
-
-    await openDesktop(page, base);
-
-    // ── 1. Launch a real module through the desktop launcher ──
-    await page.evaluate(function () { openModule("Calculator"); });
-    await page.waitForFunction(function () {
-        var titles = document.querySelectorAll(".floatWindow .controls .title");
-        for (var i = 0; i < titles.length; i++) {
-            if (titles[i].textContent.indexOf("Calculator") !== -1) return true;
-        }
-        return false;
-    }, { timeout: 20000 });
-    h.ok("openModule launches a module into a float window");
-    // A taskbar button was registered for it.
-    const hasTaskbarBtn = await page.evaluate(function () {
-        return document.querySelectorAll(".floatWindowButton").length > 0;
-    });
-    if (!hasTaskbarBtn) h.fail("launching a module did not add a taskbar button");
-    h.ok("the launched module gets a taskbar button");
-
-    // ── 2. newFloatWindow creates windows deterministically ──
-    await page.evaluate(function () {
-        newFloatWindow({ uid: "e2ewinA", url: "about:blank", title: "E2E Window A", left: 120, top: 120 });
-        newFloatWindow({ uid: "e2ewinB", url: "about:blank", title: "E2E Window B", left: 260, top: 200 });
-    });
-    await page.waitForSelector(".floatWindow[windowId='e2ewinA']", { timeout: 10000 });
-    await page.waitForSelector(".floatWindow[windowId='e2ewinB']", { timeout: 10000 });
-    h.ok("newFloatWindow creates two addressable float windows");
-
-    function zIndexOf(id) {
-        return page.evaluate(function (wid) {
-            var el = document.querySelector(".floatWindow[windowId='" + wid + "']");
-            return parseInt(window.getComputedStyle(el).zIndex, 10) || 0;
-        }, id);
-    }
-
-    // ── 3. Focus brings a window to the front ──
-    // B was created last, so it starts above A.
-    if (!((await zIndexOf("e2ewinB")) >= (await zIndexOf("e2ewinA")))) {
-        h.fail("the last-created window should start on top");
-    }
-    // Focus A by mousedown on its drag bar; it must rise above B.
-    await page.dispatchEvent(".floatWindow[windowId='e2ewinA'] .fwdragger", "mousedown", { button: 0, which: 1 });
-    await page.waitForFunction(function () {
-        function z(id) {
-            var el = document.querySelector(".floatWindow[windowId='" + id + "']");
-            return parseInt(window.getComputedStyle(el).zIndex, 10) || 0;
-        }
-        return z("e2ewinA") > z("e2ewinB");
-    }, { timeout: 10000 });
-    h.ok("focusing a window brings it above the others (z-index)");
-
-    // ── 4. Maximize / restore ──
-    await page.click(".floatWindow[windowId='e2ewinA'] .maxtoggle");
-    await page.waitForFunction(function () {
-        var el = document.querySelector(".floatWindow[windowId='e2ewinA']");
-        return el && el.getAttribute("max") === "true";
-    }, { timeout: 10000 });
-    h.ok("maximize sets the window into its maximized state");
-    await page.click(".floatWindow[windowId='e2ewinA'] .maxtoggle");
-    await page.waitForFunction(function () {
-        var el = document.querySelector(".floatWindow[windowId='e2ewinA']");
-        return el && el.getAttribute("max") === "false";
-    }, { timeout: 10000 });
-    h.ok("restore returns the window to its normal state");
-
-    // ── 5. Minimize hides the window ──
-    await page.click(".floatWindow[windowId='e2ewinA'] .mintoggle");
-    await page.waitForFunction(function () {
-        var el = document.querySelector(".floatWindow[windowId='e2ewinA']");
-        return el && window.getComputedStyle(el).display === "none";
-    }, { timeout: 10000 });
-    h.ok("minimize hides the window from the desktop");
-
-    // ── 6. Close destroys the windows ──
-    // A is hidden; close it via its own control, then close B.
-    await page.evaluate(function () {
-        closeFloatWindow($(".floatWindow[windowId='e2ewinA'] .closetoggle")[0], null);
-    });
-    await page.click(".floatWindow[windowId='e2ewinB'] .closetoggle");
-    await page.waitForFunction(function () {
-        return document.querySelectorAll(".floatWindow[windowId='e2ewinA'], .floatWindow[windowId='e2ewinB']").length === 0;
-    }, { timeout: 10000 });
-    h.ok("closing a window removes it from the desktop");
-
-    // ── 7. Wallpaper right-click shows the desktop context menu ──
-    await page.evaluate(function () {
-        var bf = document.querySelector("#bgwrapper .backgroundFrame");
-        bf.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 400, clientY: 320 }));
-    });
-    await page.waitForFunction(function () {
-        var m = document.getElementById("contextmenu");
-        return m && window.getComputedStyle(m).display !== "none" &&
-            m.textContent.indexOf("Refresh") !== -1 &&
-            m.textContent.indexOf("File Manager") !== -1;
-    }, { timeout: 10000 });
-    h.ok("right-clicking the wallpaper opens the desktop context menu");
-    // Dismiss it.
-    await page.evaluate(function () { if (typeof hideAllContextMenus === "function") hideAllContextMenus(); });
-
-    // ── 8. Icon right-click shows the icon context menu ──
-    await page.waitForFunction(function (name) {
-        var icons = document.querySelectorAll(".launchIcon");
-        for (var i = 0; i < icons.length; i++) {
-            if (icons[i].textContent.indexOf(name) !== -1) return true;
-        }
-        return false;
-    }, SC_NAME, { timeout: 20000 });
-    await page.evaluate(function (name) {
-        var icons = document.querySelectorAll(".launchIcon");
-        for (var i = 0; i < icons.length; i++) {
-            if (icons[i].textContent.indexOf(name) !== -1) {
-                icons[i].dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 200, clientY: 200 }));
-                return;
-            }
-        }
-    }, SC_NAME);
-    await page.waitForFunction(function () {
-        var m = document.getElementById("contextmenu");
-        return m && window.getComputedStyle(m).display !== "none" &&
-            m.textContent.indexOf("Open") !== -1 && m.textContent.indexOf("Delete") !== -1;
-    }, { timeout: 10000 });
-    h.ok("right-clicking a desktop icon opens the icon context menu");
-
-    // ── Cleanup ──
-    const csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify([SC_FILE]), csrft: csrft
-    });
-    await page.close();
-});

+ 0 - 168
test/e2e/playwright/specs-system/030-file-explorer.js

@@ -1,168 +0,0 @@
-/*
-    Critical path: file explorer (File Manager).
-
-    Renders the real File Manager UI against a live server, then walks
-    the whole file lifecycle through the same endpoints the UI calls:
-    list roots, list directory, create folder/file, rename, copy, move,
-    properties, recycle to trash, restore from trash and empty trash.
-    Mutating calls carry a CSRF token exactly like the front end does.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-h.run("FILE-EXPLORER", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // ── 1. File Manager UI renders ──
-    await page.goto(base + "/SystemAO/file_system/file_explorer.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#navibar", { state: "visible", timeout: 20000 });
-    await page.waitForSelector("#folderView", { state: "attached", timeout: 20000 });
-    // The sidebar lists the user's roots once listRoots returns.
-    await page.waitForFunction(function () {
-        var el = document.getElementById("userroot");
-        return el && el.textContent.trim().length > 0;
-    }, { timeout: 20000 });
-    h.ok("File Manager UI renders navibar, folder view and storage roots");
-
-    // ── 2. Storage roots include the user home ──
-    const roots = await h.getJSON(page, base + "/system/file_system/listRoots");
-    if (JSON.stringify(roots).indexOf("user") === -1) {
-        h.fail("listRoots does not include the user root: " + JSON.stringify(roots));
-    }
-    h.ok("listRoots includes the user: home root");
-
-    // ── 3. Create a folder ──
-    let csrft = await h.csrfToken(page, base);
-    let resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "folder", src: "user:/", filename: "e2e-folder", csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("newItem folder failed: " + resp);
-    let listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/" });
-    if (listing.indexOf("e2e-folder") === -1) h.fail("created folder missing from listDir: " + listing.slice(0, 300));
-    h.ok("newItem creates a folder that shows up in listDir");
-
-    // ── 4. Create a file inside it ──
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "file", src: "user:/e2e-folder", filename: "e2e-note.txt", csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("newItem file failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/e2e-folder" });
-    if (listing.indexOf("e2e-note.txt") === -1) h.fail("created file missing from listDir: " + listing.slice(0, 300));
-    h.ok("newItem creates a file inside the new folder");
-
-    // ── 5. Rename the file ──
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "rename",
-        src: JSON.stringify(["user:/e2e-folder/e2e-note.txt"]),
-        new: JSON.stringify(["e2e-renamed.txt"]),
-        csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("rename failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/e2e-folder" });
-    if (listing.indexOf("e2e-renamed.txt") === -1 || listing.indexOf("e2e-note.txt") !== -1) {
-        h.fail("rename result wrong: " + listing.slice(0, 300));
-    }
-    h.ok("fileOpr rename renames the file");
-
-    // ── 6. Copy the file to the home root ──
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "copy",
-        src: JSON.stringify(["user:/e2e-folder/e2e-renamed.txt"]),
-        dest: "user:/",
-        csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("copy failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/" });
-    if (listing.indexOf("e2e-renamed.txt") === -1) h.fail("copied file missing at destination: " + listing.slice(0, 300));
-    h.ok("fileOpr copy duplicates the file to user:/");
-
-    // ── 7. Move the copy into a second folder ──
-    csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "folder", src: "user:/", filename: "e2e-folder2", csrft: csrft
-    });
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "move",
-        src: JSON.stringify(["user:/e2e-renamed.txt"]),
-        dest: "user:/e2e-folder2",
-        csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("move failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/" });
-    if (listing.indexOf("e2e-renamed.txt") !== -1) h.fail("moved file still present at source root");
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/e2e-folder2" });
-    if (listing.indexOf("e2e-renamed.txt") === -1) h.fail("moved file missing at destination");
-    h.ok("fileOpr move relocates the file into the second folder");
-
-    // ── 8. File properties ──
-    const props = await h.getJSON(page, base + "/system/file_system/getProperties?path=" + encodeURIComponent("user:/e2e-folder"));
-    if (!props.IsDirectory || props.Basename !== "e2e-folder") {
-        h.fail("getProperties returned unexpected data: " + JSON.stringify(props));
-    }
-    h.ok("getProperties reports the folder correctly");
-
-    // ── 9. Recycle (delete to trash) ──
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "recycle",
-        src: JSON.stringify(["user:/e2e-folder/e2e-renamed.txt"]),
-        csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("recycle failed: " + resp);
-    let trash = await h.getJSON(page, base + "/system/file_system/listTrash");
-    let entry = trash.find(function (t) { return t.OriginalFilename === "e2e-renamed.txt"; });
-    if (!entry) h.fail("recycled file not found in trash: " + JSON.stringify(trash).slice(0, 300));
-    h.ok("recycle moves the file into the trash bin");
-
-    // ── 10. Restore from trash ──
-    resp = await h.postForm(page, base + "/system/file_system/restoreTrash", { src: entry.Filepath });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("restoreTrash failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/e2e-folder" });
-    if (listing.indexOf("e2e-renamed.txt") === -1) h.fail("restored file missing from original folder");
-    h.ok("restoreTrash puts the file back where it came from");
-
-    // ── 11. Recycle again and empty the trash ──
-    csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "recycle",
-        src: JSON.stringify(["user:/e2e-folder/e2e-renamed.txt"]),
-        csrft: csrft
-    });
-    resp = await h.postForm(page, base + "/system/file_system/clearTrash", {});
-    trash = await h.getJSON(page, base + "/system/file_system/listTrash");
-    entry = trash.find(function (t) { return t.OriginalFilename === "e2e-renamed.txt"; });
-    if (entry) h.fail("trash still contains the file after clearTrash");
-    h.ok("clearTrash empties the trash bin");
-
-    // ── 12. Permanent delete of the working folders (cleanup + coverage) ──
-    csrft = await h.csrfToken(page, base);
-    resp = await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete",
-        src: JSON.stringify(["user:/e2e-folder", "user:/e2e-folder2"]),
-        csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("permanent delete failed: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/" });
-    if (listing.indexOf("e2e-folder") !== -1) h.fail("folder still present after permanent delete");
-    h.ok("fileOpr delete permanently removes the folders");
-
-    // ── 13. Mutations without a CSRF token are refused ──
-    // (Match on "csrf" - the rejection text "Invalid CSRF token" itself
-    // contains the letters "ok", so a plain ok-check would misfire.)
-    resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "folder", src: "user:/", filename: "no-csrf-folder"
-    });
-    if (resp.toLowerCase().indexOf("csrf") === -1) h.fail("newItem without CSRF token was not refused: " + resp);
-    listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/" });
-    if (listing.indexOf("no-csrf-folder") !== -1) h.fail("folder was created despite missing CSRF token");
-    h.ok("mutating file API refuses requests without a CSRF token");
-
-    await page.close();
-});

+ 0 - 119
test/e2e/playwright/specs-system/035-file-transfer.js

@@ -1,119 +0,0 @@
-/*
-    Critical path (deep): file transfer, search and sharing.
-
-    Extends the file-explorer coverage in 030 with the transfer-oriented
-    File Manager features that move real bytes in and out of the system:
-    multipart upload, media download (round-trip verification), search,
-    and the share-link lifecycle (create / list / public download /
-    delete). Uses the same endpoints the File Manager front end calls.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const UPLOAD_BODY = "arozos-e2e upload payload " + Date.now();
-const UPLOAD_NAME = "e2e-upload.txt";
-
-h.run("FILE-TRANSFER", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // Work inside a dedicated folder so the test is self-contained.
-    let csrft = await h.csrfToken(page, base);
-    let resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "folder", src: "user:/", filename: "e2e-transfer", csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not create working folder: " + resp);
-
-    // ── 1. Multipart upload of a real file ──
-    const uploadResp = await page.request.post(base + "/system/file_system/upload", {
-        multipart: {
-            path: "user:/e2e-transfer",
-            file: {
-                name: UPLOAD_NAME,
-                mimeType: "text/plain",
-                buffer: Buffer.from(UPLOAD_BODY)
-            }
-        }
-    });
-    const uploadText = (await uploadResp.text()).trim().toLowerCase();
-    if (uploadText.indexOf("ok") === -1) h.fail("upload failed: " + uploadText);
-    let listing = await h.postForm(page, base + "/system/file_system/listDir", { dir: "user:/e2e-transfer" });
-    if (listing.indexOf(UPLOAD_NAME) === -1) h.fail("uploaded file missing from listDir: " + listing.slice(0, 300));
-    h.ok("multipart upload stores the file in the target folder");
-
-    // ── 2. Download the file back and verify the bytes round-trip ──
-    const dlResp = await page.request.get(base + "/media/?file=" +
-        encodeURIComponent("user:/e2e-transfer/" + UPLOAD_NAME) + "&download=true");
-    if (!dlResp.ok()) h.fail("download request failed with HTTP " + dlResp.status());
-    const dlBody = await dlResp.text();
-    if (dlBody !== UPLOAD_BODY) {
-        h.fail("downloaded bytes did not match what was uploaded (got " + dlBody.length + " bytes)");
-    }
-    h.ok("download returns the exact bytes that were uploaded");
-
-    // ── 3. Search finds the file by name ──
-    const searchResults = await h.getJSON(page, base + "/system/file_system/search?path=" +
-        encodeURIComponent("user:/e2e-transfer/") + "&keyword=e2e-upload");
-    if (JSON.stringify(searchResults).indexOf(UPLOAD_NAME) === -1) {
-        h.fail("search did not find the uploaded file: " + JSON.stringify(searchResults).slice(0, 300));
-    }
-    h.ok("file search finds the uploaded file by keyword");
-
-    // ── 4. Create a share link for the file ──
-    const share = await h.getJSON(page, base + "/system/file_system/share/new?path=" +
-        encodeURIComponent("user:/e2e-transfer/" + UPLOAD_NAME));
-    // share/new accepts POST too, but the front end also uses GET-style; use POST for parity.
-    let shareObj = share;
-    if (!shareObj || !shareObj.UUID) {
-        // Fall back to the POST form the File Manager actually submits.
-        const shareText = await h.postForm(page, base + "/system/file_system/share/new", {
-            path: "user:/e2e-transfer/" + UPLOAD_NAME
-        });
-        try { shareObj = JSON.parse(shareText); } catch (e) { shareObj = null; }
-    }
-    if (!shareObj || !shareObj.UUID) h.fail("share/new did not return a share UUID: " + JSON.stringify(share));
-    const shareUUID = shareObj.UUID;
-    h.ok("share/new creates a share link with a UUID");
-
-    // ── 5. The share appears in the user's share list ──
-    const shareList = await h.getJSON(page, base + "/system/file_system/share/list");
-    if (JSON.stringify(shareList).indexOf(shareUUID) === -1) {
-        h.fail("created share not present in share list");
-    }
-    h.ok("the share appears in the user's share list");
-
-    // ── 6. The public share link serves the file without a session ──
-    // /share/download/{uuid}/ is the direct-download endpoint (the plain
-    // /share/{uuid} path renders the preview page instead).
-    const anon = await h.newPage(env.browser);
-    const anonDl = await anon.request.get(base + "/share/download/" + shareUUID + "/");
-    if (!anonDl.ok()) h.fail("public share download failed with HTTP " + anonDl.status());
-    const anonBody = await anonDl.text();
-    if (anonBody !== UPLOAD_BODY) {
-        h.fail("public share download bytes did not match the original file");
-    }
-    // The plain share page should also be reachable anonymously (preview).
-    const anonPage = await anon.request.get(base + "/share/" + shareUUID + "/");
-    if (!anonPage.ok()) h.fail("public share preview page failed with HTTP " + anonPage.status());
-    h.ok("the public share link serves the file and preview page without a session");
-    await anon.close();
-
-    // ── 7. Deleting the share revokes public access ──
-    resp = await h.postForm(page, base + "/system/file_system/share/delete", { uuid: shareUUID });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("share delete failed: " + resp);
-    const shareListAfter = await h.getJSON(page, base + "/system/file_system/share/list");
-    if (JSON.stringify(shareListAfter).indexOf(shareUUID) !== -1) {
-        h.fail("deleted share still present in share list");
-    }
-    h.ok("deleting the share removes it from the share list");
-
-    // ── Cleanup ──
-    csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify(["user:/e2e-transfer"]), csrft: csrft
-    });
-
-    await page.close();
-});

+ 0 - 62
test/e2e/playwright/specs-system/040-system-settings.js

@@ -1,62 +0,0 @@
-/*
-    Critical path: system settings.
-
-    Opens the real System Setting web UI as the administrator, verifies
-    the settings catalogue API (including admin-only module filtering)
-    and the host information endpoint that the settings pages rely on.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-h.run("SYSTEM-SETTINGS", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // ── 1. Settings UI renders its shell and populates the card grid ──
-    await page.goto(base + "/SystemAO/system_setting/index.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#app", { state: "visible", timeout: 20000 });
-    await page.waitForSelector("#sidebar", { state: "attached", timeout: 20000 });
-    await page.waitForFunction(function () {
-        var grid = document.getElementById("card-grid");
-        return grid && grid.children.length > 0;
-    }, { timeout: 20000 });
-    h.ok("System Setting UI renders and loads setting groups");
-
-    // ── 2. Setting group catalogue contains the critical groups ──
-    const groups = await h.getJSON(page, base + "/system/setting/list");
-    const groupStr = JSON.stringify(groups);
-    ["Users", "Security", "Info"].forEach(function (g) {
-        if (groupStr.indexOf('"' + g + '"') === -1) {
-            h.fail("setting group '" + g + "' missing from /system/setting/list: " + groupStr.slice(0, 400));
-        }
-    });
-    h.ok("setting catalogue includes the Users, Security and Info groups");
-
-    // ── 3. Admin sees the admin-only user management modules ──
-    const userGroupModules = await h.getJSON(page, base + "/system/setting/list?listGroup=Users");
-    const moduleNames = userGroupModules.map(function (m) { return m.Name; });
-    ["User List", "Permission Groups"].forEach(function (name) {
-        if (moduleNames.indexOf(name) === -1) {
-            h.fail("admin should see '" + name + "' in the Users group. Got: " + moduleNames.join(", "));
-        }
-    });
-    h.ok("admin sees User List and Permission Groups setting modules");
-
-    // ── 4. Host information endpoint feeds the settings pages ──
-    const arozInfo = await h.getJSON(page, base + "/system/info/getArOZInfo");
-    if (!arozInfo.HostName || arozInfo.HostName.indexOf("E2E ArozOS") === -1) {
-        h.fail("getArOZInfo HostName unexpected: " + JSON.stringify(arozInfo));
-    }
-    h.ok("getArOZInfo reports the configured host name");
-
-    // ── 5. Permission group listing works for the administrator ──
-    const permGroups = await h.getJSON(page, base + "/system/permission/listgroup");
-    if (JSON.stringify(permGroups).indexOf("administrator") === -1) {
-        h.fail("listgroup does not include the administrator group: " + JSON.stringify(permGroups));
-    }
-    h.ok("permission group listing includes the administrator group");
-
-    await page.close();
-});

+ 0 - 122
test/e2e/playwright/specs-system/050-users-permissions.js

@@ -1,122 +0,0 @@
-/*
-    Critical path: user management and permission control.
-
-    As administrator: create a limited permission group, create a user in
-    it, then prove from a second browser context that the restricted user
-    can sign in, sees only permitted modules and is refused by admin-only
-    endpoints. Finally remove the user and the group and prove both are
-    really gone (including that the removed user can no longer sign in).
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const GROUP = "e2etesters";
-const USER = "e2euser";
-const PASS = "e2e-User-Passw0rd";
-
-h.run("USERS-PERMISSIONS", async function (env) {
-    const base = env.baseURL;
-    const adminPage = await h.newPage(env.browser);
-    await h.loginViaAPI(adminPage, base, env.admin.username, env.admin.password);
-
-    // ── 1. Create a limited permission group ──
-    let resp = await h.postForm(adminPage, base + "/system/permission/newgroup", {
-        groupname: GROUP,
-        permission: JSON.stringify(["Desktop", "File Manager"]),
-        isAdmin: "false",
-        defaultQuota: "1073741824",
-        interfaceModule: "Desktop"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("newgroup failed: " + resp);
-    let groups = await h.getJSON(adminPage, base + "/system/permission/listgroup");
-    if (JSON.stringify(groups).indexOf(GROUP) === -1) h.fail("new group missing from listgroup");
-    h.ok("admin can create a limited permission group");
-
-    // ── 2. Create a user inside that group ──
-    resp = await h.postForm(adminPage, base + "/system/auth/register", {
-        username: USER, password: PASS, group: GROUP
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("user creation failed: " + resp);
-    let users = await h.getJSON(adminPage, base + "/system/users/list?noicon=true");
-    const created = users.find(function (u) { return u[0] === USER; });
-    if (!created) h.fail("new user missing from users list: " + JSON.stringify(users));
-    if (created[1].indexOf(GROUP) === -1) h.fail("new user not in expected group: " + JSON.stringify(created));
-    h.ok("admin can create a user in the limited group");
-
-    // ── 3. The restricted user can sign in through the real form ──
-    const userPage = await h.newPage(env.browser);
-    await h.loginViaForm(userPage, base, USER, PASS);
-    if (!(await h.isLoggedIn(userPage, base))) h.fail("restricted user form login failed");
-    await userPage.goto(base + "/", { waitUntil: "domcontentloaded" });
-    if (userPage.url().indexOf("desktop.html") === -1) {
-        h.fail("restricted user did not land on the desktop: " + userPage.url());
-    }
-    h.ok("restricted user signs in and lands on the desktop");
-
-    // ── 4. Module visibility is filtered by group permission ──
-    const userModules = await h.getJSON(userPage, base + "/system/modules/list");
-    const userModuleNames = userModules.map(function (m) { return m.Name; });
-    if (userModuleNames.indexOf("File Manager") === -1) {
-        h.fail("restricted user should see File Manager. Got: " + userModuleNames.join(", "));
-    }
-    if (userModuleNames.indexOf("System Setting") !== -1) {
-        h.fail("restricted user must NOT see System Setting. Got: " + userModuleNames.join(", "));
-    }
-    h.ok("restricted user sees permitted modules only (no System Setting)");
-
-    // ── 5. Admin-only endpoints refuse the restricted user ──
-    resp = await h.postForm(userPage, base + "/system/permission/newgroup", {
-        groupname: "should-not-exist",
-        permission: JSON.stringify(["Desktop"]),
-        isAdmin: "false",
-        defaultQuota: "0",
-        interfaceModule: "Desktop"
-    });
-    if (resp.toLowerCase().indexOf("ok") !== -1) h.fail("restricted user was allowed to create a group");
-    resp = await h.postForm(userPage, base + "/system/users/removeUser", { username: env.admin.username });
-    if (resp.toLowerCase().indexOf("ok") !== -1) h.fail("restricted user was allowed to remove a user");
-    groups = await h.getJSON(adminPage, base + "/system/permission/listgroup");
-    if (JSON.stringify(groups).indexOf("should-not-exist") !== -1) {
-        h.fail("group created despite permission denial");
-    }
-    h.ok("admin-only endpoints refuse the restricted user");
-
-    // ── 6. The restricted user cannot even list permission groups ──
-    const userGroupsResp = await h.postForm(userPage, base + "/system/permission/listgroup", {});
-    if (userGroupsResp.indexOf("administrator") !== -1) {
-        h.fail("restricted user could read the permission group list");
-    }
-    h.ok("permission group listing is admin-only");
-    await userPage.close();
-
-    // ── 7. Admin removes the user; their login stops working ──
-    resp = await h.postForm(adminPage, base + "/system/users/removeUser", { username: USER });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("removeUser failed: " + resp);
-    users = await h.getJSON(adminPage, base + "/system/users/list?noicon=true");
-    if (users && users.find && users.find(function (u) { return u[0] === USER; })) {
-        h.fail("removed user still present in users list");
-    }
-    const ghostPage = await h.newPage(env.browser);
-    const loginResp = await ghostPage.request.post(base + "/system/auth/login", {
-        form: { username: USER, password: PASS, rmbme: "false" }
-    });
-    const loginBody = (await loginResp.text()).trim();
-    if (await h.isLoggedIn(ghostPage, base)) h.fail("removed user can still sign in: " + loginBody);
-    await ghostPage.close();
-    h.ok("removed user disappears from the list and can no longer sign in");
-
-    // ── 8. Admin deletes the permission group ──
-    resp = await h.postForm(adminPage, base + "/system/permission/delgroup", { groupname: GROUP });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("delgroup failed: " + resp);
-    groups = await h.getJSON(adminPage, base + "/system/permission/listgroup");
-    if (JSON.stringify(groups).indexOf(GROUP) !== -1) h.fail("group still present after delgroup");
-    h.ok("admin can delete the permission group");
-
-    // ── 9. Administrator group is protected from deletion ──
-    resp = await h.postForm(adminPage, base + "/system/permission/delgroup", { groupname: "administrator" });
-    if (resp.toLowerCase().indexOf("ok") !== -1) h.fail("administrator group deletion was allowed!");
-    h.ok("administrator group cannot be deleted");
-
-    await adminPage.close();
-});

+ 0 - 85
test/e2e/playwright/specs-system/055-account.js

@@ -1,85 +0,0 @@
-/*
-    Critical path: account self-management (password change).
-
-    Exercises the "My Account" flow end to end on a dedicated throwaway
-    user (so the shared admin credentials the other specs rely on stay
-    intact): the account settings UI renders, userinfo reports identity,
-    a wrong old password is refused, a correct change succeeds, and
-    afterwards only the new password can sign in.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const USER = "e2eaccount";
-const OLD_PASS = "e2e-Old-Passw0rd";
-const NEW_PASS = "e2e-New-Passw0rd";
-
-function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
-
-h.run("ACCOUNT", async function (env) {
-    const base = env.baseURL;
-
-    // Admin creates the throwaway account.
-    const adminPage = await h.newPage(env.browser);
-    await h.loginViaAPI(adminPage, base, env.admin.username, env.admin.password);
-    let resp = await h.postForm(adminPage, base + "/system/auth/register", {
-        username: USER, password: OLD_PASS, group: "administrator"
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not create test account: " + resp);
-    h.ok("admin creates a throwaway account for the password-change test");
-
-    // The user signs in and opens their account settings page.
-    const userPage = await h.newPage(env.browser);
-    await h.loginViaAPI(userPage, base, USER, OLD_PASS);
-    await userPage.goto(base + "/SystemAO/users/account.html", { waitUntil: "domcontentloaded" });
-    await userPage.waitForSelector("#heroAvatar", { state: "attached", timeout: 20000 });
-    h.ok("account settings page renders for the signed-in user");
-
-    // userinfo reports this user's identity.
-    const info = await h.getJSON(userPage, base + "/system/users/userinfo");
-    if (JSON.stringify(info).indexOf(USER) === -1) {
-        h.fail("userinfo did not report the signed-in user: " + JSON.stringify(info).slice(0, 200));
-    }
-    h.ok("userinfo reports the signed-in user's identity");
-
-    // ── Wrong old password is refused ──
-    resp = await h.postForm(userPage, base + "/system/users/userinfo", {
-        opr: "changepw", oldpw: "not-the-old-password", newpw: NEW_PASS
-    });
-    if (resp.toLowerCase().indexOf("error") === -1) {
-        h.fail("password change with a wrong old password was not refused: " + resp);
-    }
-    h.ok("password change with a wrong old password is refused");
-
-    // ── Correct old password changes the password ──
-    resp = await h.postForm(userPage, base + "/system/users/userinfo", {
-        opr: "changepw", oldpw: OLD_PASS, newpw: NEW_PASS
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("valid password change failed: " + resp);
-    h.ok("password change with the correct old password succeeds");
-
-    // ── The old password no longer signs in ──
-    const probe = await h.newPage(env.browser);
-    const oldLogin = await probe.request.post(base + "/system/auth/login", {
-        form: { username: USER, password: OLD_PASS, rmbme: "false" }
-    });
-    await oldLogin.text();
-    if (await h.isLoggedIn(probe, base)) h.fail("the old password still signs in after the change");
-    h.ok("the old password no longer signs in");
-
-    // A failed login throttles this user/IP for ~2s; wait it out, then the
-    // new password must work.
-    await sleep(3000);
-    await h.loginViaAPI(probe, base, USER, NEW_PASS);
-    if (!(await h.isLoggedIn(probe, base))) h.fail("the new password does not sign in");
-    h.ok("the new password signs in");
-    await probe.close();
-    await userPage.close();
-
-    // ── Cleanup: admin removes the throwaway account ──
-    resp = await h.postForm(adminPage, base + "/system/users/removeUser", { username: USER });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not remove test account: " + resp);
-    h.ok("admin removes the throwaway account");
-    await adminPage.close();
-});

+ 0 - 89
test/e2e/playwright/specs-system/060-webapps-core.js

@@ -1,89 +0,0 @@
-/*
-    WebApp wave 1: core daily-driver apps.
-
-    Smoke coverage for the highest-importance WebApps - the default
-    openers for everyday file types - served by the real ArozOS server
-    with a real authenticated session:
-
-        NotepadA, Text, Photo, Music, Video, PDF Viewer, Zip File Manager
-
-    Each app must load its entry page and render its key UI element.
-    NotepadA additionally opens a real file through the same
-    hash-parameter convention the desktop uses (ao_module_loadInputFiles).
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-// App entry pages and the element that proves the app booted.
-const APPS = [
-    { name: "NotepadA", path: "/NotepadA/index.html", selector: "#codeArea" },
-    { name: "Text", path: "/Text/index.html", selector: "#toolbar" },
-    { name: "Photo", path: "/Photo/index.html", selector: "#content-area" },
-    { name: "Music", path: "/Music/index.html", selector: "#mainMenu" },
-    { name: "Video", path: "/Video/index.html", selector: "#playList" },
-    { name: "PDF Viewer", path: "/PDF%20Viewer/viewer.html", selector: "#outerContainer" },
-    { name: "Zip File Manager", path: "/Zip%20File%20Manager/index.html", selector: "#listContainer" }
-];
-
-h.run("WEBAPPS-CORE", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-
-    // ── 1. Every core app boots and renders its main UI ──
-    for (const app of APPS) {
-        await page.goto(base + app.path, { waitUntil: "domcontentloaded" });
-        try {
-            await page.waitForSelector(app.selector, { state: "attached", timeout: 20000 });
-        } catch (e) {
-            h.fail(app.name + " did not render " + app.selector + " at " + app.path);
-        }
-        h.ok(app.name + " boots and renders its main UI");
-    }
-
-    // ── 2. NotepadA opens a real file through the desktop convention ──
-    const csrft = await h.csrfToken(page, base);
-    const resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "file", src: "user:/", filename: "e2e-open.txt", csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not create test file: " + resp);
-
-    const fileRef = encodeURIComponent(JSON.stringify([
-        { filename: "e2e-open.txt", filepath: "user:/e2e-open.txt" }
-    ]));
-    await page.goto(base + "/NotepadA/index.html#" + fileRef, { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#codeArea", { state: "attached", timeout: 20000 });
-    try {
-        // Each opened file becomes a .fileTab carrying its filepath in the
-        // "filename" attribute (the visible label loads asynchronously).
-        await page.waitForFunction(function () {
-            var tabs = document.querySelectorAll(".fileTab");
-            for (var i = 0; i < tabs.length; i++) {
-                var fn = tabs[i].getAttribute("filename") || "";
-                if (fn.indexOf("e2e-open.txt") !== -1) { return true; }
-            }
-            return false;
-        }, { timeout: 20000 });
-    } catch (e) {
-        h.fail("NotepadA did not open e2e-open.txt in a tab");
-    }
-    h.ok("NotepadA opens a file passed via the desktop hash convention");
-
-    // ── 3. Music app can see the user's storage roots ──
-    // (Music builds its library from the same listRoots/listDir APIs; a
-    // quick API probe under this session guards the data path the app uses.)
-    const roots = await h.getJSON(page, base + "/system/file_system/listRoots");
-    if (JSON.stringify(roots).indexOf("user") === -1) {
-        h.fail("media apps have no user root to browse");
-    }
-    h.ok("media apps have the user storage root available");
-
-    // Cleanup
-    const csrft2 = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify(["user:/e2e-open.txt"]), csrft: csrft2
-    });
-
-    await page.close();
-});

+ 0 - 86
test/e2e/playwright/specs-system/070-webapps-office.js

@@ -1,86 +0,0 @@
-/*
-    WebApp wave 2: office / productivity apps.
-
-    Smoke coverage for the office and productivity WebApps served by the
-    real ArozOS server with an authenticated session:
-
-        Code Studio, MDEditor, Calendar, Notes, Memo, Reminders,
-        OfficeViewer, Dashboard (alternate interface module)
-
-    Each app must load its entry page and render its key UI element.
-    MDEditor additionally opens a real Markdown file through the desktop
-    hash-parameter convention (ao_module_loadInputFiles).
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const APPS = [
-    { name: "Code Studio", path: "/Code%20Studio/index.html", selector: "#directoryExplorer" },
-    { name: "MDEditor", path: "/MDEditor/mde.html", selector: "#maintext" },
-    { name: "Calendar", path: "/Calendar/index.html", selector: "#viewArea" },
-    { name: "Notes", path: "/Notes/index.html", selector: "#noteList" },
-    { name: "Memo", path: "/Memo/index.html", selector: "#memobox" },
-    { name: "Reminders", path: "/Reminders/index.html", selector: "#smartGrid" },
-    { name: "OfficeViewer", path: "/OfficeViewer/index.html", selector: "div.container" },
-    { name: "Dashboard", path: "/Dashboard/index.html", selector: "#minitoolsWidget" }
-];
-
-h.run("WEBAPPS-OFFICE", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-    // OfficeViewer pops a file selector on load; auto-dismiss any dialog.
-    page.on("dialog", function (d) { d.dismiss().catch(function () {}); });
-
-    // ── 1. Every office app boots and renders its main UI ──
-    for (const app of APPS) {
-        await page.goto(base + app.path, { waitUntil: "domcontentloaded" });
-        try {
-            await page.waitForSelector(app.selector, { state: "attached", timeout: 20000 });
-        } catch (e) {
-            h.fail(app.name + " did not render " + app.selector + " at " + app.path);
-        }
-        h.ok(app.name + " boots and renders its main UI");
-    }
-
-    // ── 2. MDEditor opens a real Markdown file via the hash convention ──
-    let csrft = await h.csrfToken(page, base);
-    let resp = await h.postForm(page, base + "/system/file_system/newItem", {
-        type: "file", src: "user:/", filename: "e2e-doc.md", csrft: csrft
-    });
-    if (resp.toLowerCase().indexOf("ok") === -1) h.fail("could not create markdown test file: " + resp);
-
-    const fileRef = encodeURIComponent(JSON.stringify([
-        { filename: "e2e-doc.md", filepath: "user:/e2e-doc.md" }
-    ]));
-    await page.goto(base + "/MDEditor/mde.html#" + fileRef, { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#maintext", { state: "attached", timeout: 20000 });
-    // The editor sets its window title from the opened filename once loaded.
-    try {
-        await page.waitForFunction(function () {
-            return document.title.indexOf("e2e-doc") !== -1;
-        }, { timeout: 15000 });
-        h.ok("MDEditor opens a Markdown file passed via the desktop hash convention");
-    } catch (e) {
-        // Title propagation varies; fall back to asserting the editor is live.
-        const editorReady = await page.isVisible("#maintext");
-        if (!editorReady) h.fail("MDEditor did not become ready with the opened file");
-        h.ok("MDEditor loads with an opened Markdown file (editor ready)");
-    }
-
-    // ── 3. Dashboard reads live system stats through the desktop APIs ──
-    const hostInfo = await h.getJSON(page, base + "/system/desktop/host");
-    if (JSON.stringify(hostInfo).indexOf("E2E ArozOS") === -1) {
-        h.fail("Dashboard's host info source did not return the expected host");
-    }
-    h.ok("Dashboard's system-info data source is reachable");
-
-    // Cleanup
-    csrft = await h.csrfToken(page, base);
-    await h.postForm(page, base + "/system/file_system/fileOpr", {
-        opr: "delete", src: JSON.stringify(["user:/e2e-doc.md"]), csrft: csrft
-    });
-
-    await page.close();
-});

+ 0 - 58
test/e2e/playwright/specs-system/080-webapps-media.js

@@ -1,58 +0,0 @@
-/*
-    WebApp wave 3: media / creative apps.
-
-    Smoke coverage for the media and creative WebApps served by the real
-    ArozOS server with an authenticated session:
-
-        Musicify, Movie, Manga, Paint, Pixel Studio, Audio Studio,
-        Camera, Recorder, FFmpeg Factory
-
-    (Cine Studio has its own deep static suite under specs/, so it is not
-    repeated here.) Each app must load its entry page and render its key
-    UI element. Camera/Recorder request getUserMedia, which is denied in
-    headless Chromium - the assertion targets the shell, not the stream.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const APPS = [
-    { name: "Musicify", path: "/Musicify/index.html", selector: "#musicPlayer" },
-    { name: "Movie", path: "/Movie/index.html", selector: "#mode-tabs" },
-    { name: "Manga", path: "/Manga/index.html", selector: "#appHeader" },
-    { name: "Paint", path: "/Paint/index.html", selector: ".ptro-bar" },
-    { name: "Pixel Studio", path: "/Pixel%20Studio/index.html", selector: "#toolbar-buttons" },
-    { name: "Audio Studio", path: "/Audio%20Studio/index.html", selector: "#toolbar" },
-    { name: "Camera", path: "/Camera/index.html", selector: "#viewfinder" },
-    { name: "Recorder", path: "/Recorder/index.html", selector: "#record" },
-    { name: "FFmpeg Factory", path: "/FFmpeg%20Factory/index.html", selector: "#leftPanel" }
-];
-
-h.run("WEBAPPS-MEDIA", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-    page.on("dialog", function (d) { d.dismiss().catch(function () {}); });
-
-    // ── 1. Every media app boots and renders its main UI ──
-    for (const app of APPS) {
-        await page.goto(base + app.path, { waitUntil: "domcontentloaded" });
-        try {
-            await page.waitForSelector(app.selector, { state: "attached", timeout: 20000 });
-        } catch (e) {
-            h.fail(app.name + " did not render " + app.selector + " at " + app.path);
-        }
-        h.ok(app.name + " boots and renders its main UI");
-    }
-
-    // ── 2. Media browsers can reach the user's storage to build libraries ──
-    // Musicify / Movie / Manga all populate their library from listRoots +
-    // listDir; a probe under this session guards the data path they use.
-    const roots = await h.getJSON(page, base + "/system/file_system/listRoots");
-    if (JSON.stringify(roots).indexOf("user") === -1) {
-        h.fail("media library apps have no user root to browse");
-    }
-    h.ok("media library apps have the user storage root available");
-
-    await page.close();
-});

+ 0 - 78
test/e2e/playwright/specs-system/090-webapps-utilities.js

@@ -1,78 +0,0 @@
-/*
-    WebApp wave 4: utilities / developer / network apps.
-
-    Smoke coverage for the remaining WebApps served by the real ArozOS
-    server with an authenticated session:
-
-        Calculator, Clock, Browser, Speedtest, Web Downloader,
-        Web Builder, SQLite Admin, Terminal, AGIForge, AIChat, OTPAuth,
-        Productivity, OnScreenKeyboard, Arozcast, Management Gateway,
-        UnitTest, CronDemo, Serverless
-
-    Each app must load its entry page and render its key UI element.
-    Calculator additionally does a real end-to-end calculation through
-    its own UI to prove interactivity, not just rendering.
-*/
-"use strict";
-
-const h = require("../lib/system-harness");
-
-const APPS = [
-    { name: "Calculator", path: "/Calculator/index.html", selector: "#expression" },
-    { name: "Clock", path: "/Clock/index.html", selector: "#panel-clock" },
-    { name: "Browser", path: "/Browser/index.html", selector: "#urlbar" },
-    { name: "Speedtest", path: "/Speedtest/index.html", selector: "#progressTrack" },
-    { name: "Web Downloader", path: "/Web%20Downloader/index.html", selector: "#downloadbtn" },
-    { name: "Web Builder", path: "/Web%20Builder/index.html", selector: "#editorFrame" },
-    { name: "SQLite Admin", path: "/SQLite%20Admin/index.html", selector: "#btn-open-db" },
-    { name: "Terminal", path: "/Terminal/index.html", selector: "#termOutput" },
-    { name: "AGIForge", path: "/AGIForge/index.html", selector: "#convo" },
-    { name: "AIChat", path: "/AIChat/index.html", selector: ".composer" },
-    { name: "OTPAuth", path: "/OTPAuth/index.html", selector: "#sidebar" },
-    { name: "Productivity", path: "/Productivity/index.html", selector: "#toolGrid" },
-    { name: "OnScreenKeyboard", path: "/OnScreenKeyboard/index.html", selector: ".keyboard" },
-    { name: "Arozcast", path: "/Arozcast/index.html", selector: "#app" },
-    { name: "Management Gateway", path: "/Management%20Gateway/index.html", selector: "#mainframe" },
-    { name: "UnitTest", path: "/UnitTest/index.html", selector: "#btnRunAll" },
-    { name: "CronDemo", path: "/CronDemo/index.html", selector: "#status-card" },
-    { name: "Serverless", path: "/Serverless/index.html", selector: "#global-stats" }
-];
-
-h.run("WEBAPPS-UTILITIES", async function (env) {
-    const base = env.baseURL;
-    const page = await h.newPage(env.browser);
-    await h.loginViaAPI(page, base, env.admin.username, env.admin.password);
-    page.on("dialog", function (d) { d.dismiss().catch(function () {}); });
-
-    // ── 1. Every utility app boots and renders its main UI ──
-    for (const app of APPS) {
-        await page.goto(base + app.path, { waitUntil: "domcontentloaded" });
-        try {
-            await page.waitForSelector(app.selector, { state: "attached", timeout: 20000 });
-        } catch (e) {
-            h.fail(app.name + " did not render " + app.selector + " at " + app.path);
-        }
-        h.ok(app.name + " boots and renders its main UI");
-    }
-
-    // ── 2. Calculator performs a real calculation through its UI ──
-    await page.goto(base + "/Calculator/index.html", { waitUntil: "domcontentloaded" });
-    await page.waitForSelector("#result", { state: "attached", timeout: 20000 });
-    // Buttons carry their glyph as text; click 7 + 8 = and read the result.
-    async function clickKey(label) {
-        await page.click("xpath=(//button[normalize-space(.)='" + label + "'])[1]");
-    }
-    await clickKey("7");
-    await clickKey("+");
-    await clickKey("8");
-    await clickKey("=");
-    await page.waitForFunction(function () {
-        var r = document.getElementById("result");
-        return r && r.textContent.replace(/[^0-9]/g, "").indexOf("15") !== -1;
-    }, { timeout: 10000 }).catch(function () {
-        h.fail("Calculator did not compute 7 + 8 = 15");
-    });
-    h.ok("Calculator computes 7 + 8 = 15 through its UI");
-
-    await page.close();
-});

+ 0 - 284
test/e2e/playwright/specs/editing.js

@@ -1,284 +0,0 @@
-/*
-    Iteration 4 tests: autosave/recovery, recents, copy/paste/duplicate,
-    multi-select, ripple delete, markers, snap toggle, clip speed,
-    blend modes, flip, JKL shuttle, loop, export frame, detach audio,
-    audio track solo.
-*/
-"use strict";
-
-const { ok, fail, run } = require("../lib/harness");
-
-run("EDITING", async (page, browser) => {
-    await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
-
-    /* ---- seed real media ---- */
-    await page.evaluate(async () => {
-        function makeVideo(seconds, hue) {
-            return new Promise((resolve) => {
-                const cv = document.createElement("canvas");
-                cv.width = 320; cv.height = 180;
-                const ctx = cv.getContext("2d");
-                const stream = cv.captureStream(15);
-                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
-                const chunks = [];
-                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
-                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
-                const t0 = performance.now();
-                (function draw() {
-                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
-                    ctx.fillRect(0, 0, 320, 180);
-                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
-                })();
-                rec.start(200);
-            });
-        }
-        const v = await makeVideo(3, 210);
-        window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
-        // asymmetric test image: left red, right blue
-        const ic = document.createElement("canvas");
-        ic.width = 320; ic.height = 180;
-        const ictx = ic.getContext("2d");
-        ictx.fillStyle = "#c62828"; ictx.fillRect(0, 0, 160, 180);
-        ictx.fillStyle = "#1e63c9"; ictx.fillRect(160, 0, 160, 180);
-        window.__img = CS.media.register({ name: "Split.png", blobUrl: ic.toDataURL("image/png"), type: "image" });
-    });
-    await page.waitForFunction(() => window.__vid.probed && window.__img.probed, { timeout: 30000 });
-
-    const px = (fx, fy) => page.evaluate(([fx, fy]) => {
-        const cv = document.getElementById("preview-canvas");
-        const d = cv.getContext("2d").getImageData(Math.floor(cv.width * fx), Math.floor(cv.height * fy), 1, 1).data;
-        return [d[0], d[1], d[2]];
-    }, [fx, fy]);
-
-    /* ---- 1. copy / paste / duplicate / multi-select / ripple ---- */
-    const editing = await page.evaluate(() => {
-        const c1 = CS.addClipToTimeline(window.__vid, "V1", 0);
-        const c2 = CS.addClipToTimeline(window.__vid, "V1", CS.clipDuration(c1));
-        CS.commit("seed");
-        window.__c1 = c1; window.__c2 = c2;
-
-        // multi-select both
-        CS.selectClip(c1.id);
-        CS.toggleSelectClip(c2.id);
-        const multi = CS.state.selectedClipIds.length;
-
-        // copy + paste at playhead 10
-        CS.copySelectedClips();
-        CS.state.playhead = 10; //seek() clamps to timeline duration
-        CS.pasteClipsAtPlayhead();
-        const afterPaste = CS.project.clips.length;
-
-        // duplicate the pasted pair
-        CS.duplicateSelectedClips();
-        const afterDup = CS.project.clips.length;
-
-        // group delete the 4 new ones
-        CS.deleteSelectedClip(); // deletes duplicated selection (2)
-        CS.selectClip(CS.project.clips.filter(c => c.start >= 9)[0].id);
-        CS.toggleSelectClip(CS.project.clips.filter(c => c.start >= 9)[1].id);
-        CS.deleteSelectedClip();
-        const afterCleanup = CS.project.clips.length;
-
-        // ripple delete c1: c2 shifts to 0
-        CS.selectClip(window.__c1.id);
-        CS.rippleDeleteSelected();
-        return {
-            multi, afterPaste, afterDup, afterCleanup,
-            c2start: CS.getClip(window.__c2.id).start,
-            n: CS.project.clips.length
-        };
-    });
-    if (editing.multi !== 2) fail("multi-select failed");
-    if (editing.afterPaste !== 4) fail("paste failed: " + editing.afterPaste);
-    if (editing.afterDup !== 6) fail("duplicate failed: " + editing.afterDup);
-    if (editing.afterCleanup !== 2) fail("group delete failed: " + editing.afterCleanup);
-    if (editing.n !== 1 || editing.c2start > 0.01) fail("ripple delete did not close gap: " + JSON.stringify(editing));
-    ok(`multi-select/copy/paste/duplicate/group-delete/ripple all work (c2 rippled to ${editing.c2start})`);
-
-    /* ---- 2. markers ---- */
-    const markers = await page.evaluate(() => {
-        CS.player.seek(1.0); CS.toggleMarkerAtPlayhead();
-        CS.player.seek(2.0); CS.toggleMarkerAtPlayhead();
-        CS.player.seek(0);
-        CS.gotoMarker(1);
-        const jumped = CS.state.playhead;
-        CS.player.seek(2.0);
-        CS.toggleMarkerAtPlayhead(); // remove marker at 2.0
-        return { n: CS.project.markers.length, jumped };
-    });
-    if (Math.abs(markers.jumped - 1.0) > 0.02) fail("goto marker failed: " + markers.jumped);
-    if (markers.n !== 1) fail("marker toggle-remove failed: " + markers.n);
-    ok("markers: add, jump, remove");
-
-    /* ---- 3. snap toggle ---- */
-    const snap = await page.evaluate(() => {
-        const clip = CS.project.clips[0];
-        CS.state.snap = true;
-        const snapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
-        CS.state.snap = false;
-        const unsnapped = CS.timeline.applySnap(CS.state.playhead + 0.05, clip, "trim");
-        CS.state.snap = true;
-        return { snapped, unsnapped, ph: CS.state.playhead };
-    });
-    if (Math.abs(snap.snapped - snap.ph) > 0.001) fail("snap did not snap to playhead");
-    if (Math.abs(snap.unsnapped - snap.ph) < 0.001) fail("snap-off still snapped");
-    ok("snapping toggle works");
-
-    /* ---- 4. clip speed ---- */
-    const speed = await page.evaluate(() => {
-        const clip = CS.project.clips[0];
-        window.__c = clip;
-        const durBefore = CS.clipDuration(clip);
-        clip.props.speed = 2;
-        CS.commit("speed");
-        const durAfter = CS.clipDuration(clip);
-        CS.player.seek(0.5);
-        const el = CS.player.pool[clip.id];
-        return { durBefore, durAfter, elTime: el ? el.currentTime : -1, expect: clip.in + 0.5 * 2 };
-    });
-    if (Math.abs(speed.durAfter - speed.durBefore / 2) > 0.01) fail("2x speed did not halve duration");
-    if (Math.abs(speed.elTime - speed.expect) > 0.1) fail("speed-aware seek wrong: " + JSON.stringify(speed));
-    ok(`clip speed 2x: duration ${speed.durBefore.toFixed(2)}s -> ${speed.durAfter.toFixed(2)}s, source seek correct`);
-    await page.evaluate(() => { window.__c.props.speed = 1; CS.commit("speed reset"); });
-
-    /* ---- 5. flip horizontal (asymmetric image) ---- */
-    await page.evaluate(() => {
-        CS.project.clips = [];
-        const c = CS.addClipToTimeline(window.__img, "V1", 0);
-        c.props.crop = "fill";
-        window.__ic = c;
-        CS.commit("img");
-        CS.player.seek(0.5);
-    });
-    await page.waitForTimeout(300);
-    const left0 = await px(0.25, 0.5);
-    await page.evaluate(() => { window.__ic.props.flipH = true; CS.player.render(); });
-    const left1 = await px(0.25, 0.5);
-    if (!(left0[0] > 140 && left0[2] < 90)) fail("baseline left not red: " + left0);
-    if (!(left1[2] > 120 && left1[0] < 90)) fail("flipH left not blue: " + left1);
-    ok(`flip horizontal verified (left rgb ${left0} -> ${left1})`);
-
-    /* ---- 6. blend mode (additive over color boards) ---- */
-    const blend = await page.evaluate(() => {
-        CS.project.clips = [];
-        CS.player.seek(0.5);
-        CS.titles.insertElement("red");    // lands on V1 (empty)
-        const red = CS.selectedClip();
-        red.start = 0;
-        CS.titles.insertElement("blue");   // V1 busy -> V2
-        const blue = CS.selectedClip();
-        blue.start = 0;
-        blue.props.blend = "lighter";
-        CS.commit("blend");
-        CS.player.render();
-        const cv = document.getElementById("preview-canvas");
-        const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
-        return [d[0], d[1], d[2]];
-    });
-    if (!(blend[0] > 140 && blend[2] > 140)) fail("additive blend not magenta-ish: " + blend);
-    ok(`blend mode Add composites (rgb ${blend})`);
-
-    /* ---- 7. JKL + loop ---- */
-    await page.evaluate(() => {
-        CS.project.clips = [];
-        const c = CS.addClipToTimeline(window.__vid, "V1", 0);
-        c.out = Math.min(c.out, 1.2);
-        CS.commit("loop seed");
-        CS.player.seek(0);
-    });
-    await page.keyboard.press("l");
-    await page.keyboard.press("l");
-    const rate = await page.evaluate(() => CS.player.rate);
-    if (rate !== 2) fail("LL did not reach 2x: " + rate);
-    await page.keyboard.press("k");
-    const paused = await page.evaluate(() => !CS.state.playing);
-    if (!paused) fail("K did not pause");
-    await page.evaluate(() => { CS.state.loop = true; CS.player.seek(0); });
-    await page.click("#btn-play");
-    await page.waitForTimeout(1900); // > clip length: must have looped
-    const loopState = await page.evaluate(() => ({ playing: CS.state.playing, ph: CS.state.playhead, dur: CS.timelineDuration() }));
-    if (!loopState.playing) fail("loop did not keep playing: " + JSON.stringify(loopState));
-    if (loopState.ph >= loopState.dur) fail("loop did not wrap");
-    await page.evaluate(() => { CS.player.pause(); CS.state.loop = false; });
-    ok(`JKL shuttle + loop playback (wrapped to ${loopState.ph.toFixed(2)}s)`);
-
-    /* ---- 8. export current frame (PNG) ---- */
-    const frameSize = await page.evaluate(() => new Promise((resolve) => {
-        CS._forceStandalone = true; // use the download path in the test env
-        CS.fileio.downloadBlob = function (blob) { resolve(blob.size); };
-        CS.player.seek(0.4);
-        CS.exporter.exportFrame();
-    }));
-    await page.evaluate(() => { CS._forceStandalone = false; });
-    if (frameSize < 2000) fail("frame PNG too small: " + frameSize);
-    ok(`export current frame produced PNG of ${frameSize} bytes`);
-
-    /* ---- 9. detach audio ---- */
-    const detach = await page.evaluate(() => {
-        CS.project.clips = [];
-        const c = CS.addClipToTimeline(window.__vid, "V1", 0);
-        CS.commit("detach seed");
-        CS.detachAudio(c);
-        const audioClips = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio");
-        return {
-            n: CS.project.clips.length,
-            audio: audioClips.length,
-            origVol: c.props.volume,
-            flagged: c.props.audioDetached,
-            track: audioClips.length ? audioClips[0].trackId : ""
-        };
-    });
-    if (detach.n !== 2 || detach.audio !== 1) fail("detach did not create audio clip: " + JSON.stringify(detach));
-    if (detach.origVol !== 0 || !detach.flagged) fail("original clip audio not silenced");
-    ok(`detach audio -> ${detach.track}, original muted`);
-
-    /* ---- 10. track solo ---- */
-    const solo = await page.evaluate(() => {
-        const a1 = CS.getTrack("A1");
-        a1.solo = true;
-        const det = CS.project.clips.filter(cl => CS.getTrack(cl.trackId).kind === "audio")[0];
-        // move detached clip to A2 so it should be muted by the solo
-        det.trackId = "A2";
-        CS.player.syncElements();
-        const el = CS.player.pool[det.id];
-        const mutedWhenSolo = el.muted;
-        a1.solo = false;
-        CS.player.syncElements();
-        return { mutedWhenSolo, mutedAfter: el.muted };
-    });
-    if (!solo.mutedWhenSolo) fail("solo did not mute other audio track");
-    if (solo.mutedAfter) fail("unsolo did not restore");
-    ok("audio track solo mutes other audio tracks");
-
-    /* ---- 11. autosave + recovery ---- */
-    await page.evaluate(() => {
-        CS.project.name = "RecoverMe";
-        CS.markDirty();
-        CS.session.saveSnapshot();
-    });
-    const hasSnap = await page.evaluate(() => !!localStorage.getItem("cinestudio_autosave"));
-    if (!hasSnap) fail("autosave snapshot missing");
-    await page.reload({ waitUntil: "networkidle" });
-    await page.waitForFunction(() => window.CS && CS.project);
-    await page.waitForSelector(".modal", { timeout: 5000 }).catch(() => fail("recovery modal did not appear"));
-    const modalText = await page.evaluate(() => document.querySelector(".modal").textContent);
-    if (modalText.indexOf("RecoverMe") < 0) fail("recovery modal missing project name");
-    await page.click(".modal-btn.primary"); // Restore
-    const restored = await page.evaluate(() => ({
-        name: CS.project.name,
-        clips: CS.project.clips.length
-    }));
-    if (restored.name !== "RecoverMe") fail("restore did not recover name: " + restored.name);
-    if (restored.clips < 2) fail("restore did not recover clips: " + restored.clips);
-    ok(`autosave recovery restored "${restored.name}" with ${restored.clips} clips`);
-
-    /* ---- 12. recent projects list ---- */
-    const recents = await page.evaluate(() => {
-        CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine");
-        CS.session.recordRecent("Beta", "user:/Cine Studio/Projects/Beta.cine");
-        CS.session.recordRecent("Alpha", "user:/Cine Studio/Projects/Alpha.cine"); // dedupe to front
-        return CS.session.recents().map(r => r.name);
-    });
-    if (recents.length !== 2 || recents[0] !== "Alpha") fail("recents list wrong: " + recents);
-    ok("recent projects list records and dedupes");
-});

+ 0 - 226
test/e2e/playwright/specs/features.js

@@ -1,226 +0,0 @@
-/*
-    Feature test for the second iteration: effects, titles, transitions,
-    elements, filters, list view - verified end to end with real
-    in-browser generated media and pixel sampling.
-*/
-"use strict";
-
-const { ok, fail, run } = require("../lib/harness");
-
-run("FEATURE", async (page, browser) => {
-
-    // traffic lights must be gone
-    const hasTL = await page.evaluate(() => !!document.getElementById("traffic-lights"));
-    if (hasTL) fail("traffic lights still present");
-    ok("macOS traffic lights removed");
-
-    /* ---- media: one real video clip ---- */
-    await page.evaluate(async () => {
-        function makeVideo(seconds, hue) {
-            return new Promise((resolve) => {
-                const cv = document.createElement("canvas");
-                cv.width = 320; cv.height = 180;
-                const ctx = cv.getContext("2d");
-                const stream = cv.captureStream(15);
-                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
-                const chunks = [];
-                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
-                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
-                const t0 = performance.now();
-                (function draw() {
-                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
-                    ctx.fillRect(0, 0, 320, 180);
-                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
-                })();
-                rec.start(200);
-            });
-        }
-        const blob = await makeVideo(3, 210);
-        window.__m = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(blob), type: "video" });
-    });
-    await page.waitForFunction(() => window.__m.probed, { timeout: 30000 });
-
-    const samplePx = () => page.evaluate(() => {
-        const cv = document.getElementById("preview-canvas");
-        const d = cv.getContext("2d").getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
-        return [d[0], d[1], d[2]];
-    });
-
-    /* ---- effects ---- */
-    await page.evaluate(() => {
-        const c = CS.addClipToTimeline(window.__m, "V1", 0);
-        window.__clip = c;
-        CS.commit("seed");
-        CS.selectClip(c.id);
-        CS.player.seek(1.0);
-    });
-    await page.waitForTimeout(700);
-    const base = await samplePx();
-    if (base[0] + base[1] + base[2] < 20) fail("baseline frame is black");
-
-    await page.evaluate(() => CS.effects.applyToClip(window.__clip, "bw"));
-    await page.waitForTimeout(250);
-    const bw = await samplePx();
-    if (Math.abs(bw[0] - bw[1]) > 6 || Math.abs(bw[1] - bw[2]) > 6) fail("B&W effect not applied: " + bw);
-    ok(`B&W effect desaturates (rgb ${bw})`);
-
-    await page.evaluate(() => {
-        CS.effects.removeFromClip(window.__clip, "bw");
-        CS.effects.applyToClip(window.__clip, "invert");
-    });
-    await page.waitForTimeout(250);
-    const inv = await samplePx();
-    if (Math.abs(inv[0] - base[0]) < 30 && Math.abs(inv[2] - base[2]) < 30) fail("invert changed nothing: " + inv + " vs " + base);
-    ok(`invert flips colors (rgb ${base} -> ${inv})`);
-
-    await page.evaluate(() => {
-        CS.effects.removeFromClip(window.__clip, "invert");
-        CS.effects.applyToClip(window.__clip, "fadein"); // 1s default
-        CS.player.seek(0.06);
-    });
-    await page.waitForTimeout(500);
-    const faded = await samplePx();
-    if (faded[0] + faded[1] + faded[2] > base[0] + base[1] + base[2] * 0.5) fail("fade-in start not dark: " + faded);
-    await page.evaluate(() => CS.player.seek(2.0));
-    await page.waitForTimeout(400);
-    const unfaded = await samplePx();
-    if (unfaded[0] + unfaded[1] + unfaded[2] < 20) fail("frame after fade window is dark");
-    ok(`fade-in ramps alpha (start rgb ${faded}, later rgb ${unfaded})`);
-
-    await page.evaluate(() => {
-        CS.effects.applyToClip(window.__clip, "pixelate");
-        CS.effects.applyToClip(window.__clip, "vignette");
-        CS.effects.applyToClip(window.__clip, "grain");
-        CS.player.seek(1.5);
-    });
-    await page.waitForTimeout(300);
-    const fxCount = await page.evaluate(() => window.__clip.props.effects.length);
-    if (fxCount !== 4) fail("expected 4 effects on clip, got " + fxCount);
-    const fxBadge = await page.evaluate(() => !!document.querySelector(".tl-clip .clip-fx"));
-    if (!fxBadge) fail("fx badge missing on timeline clip");
-    ok("pixelate + vignette + grain stack renders, fx badge shown");
-
-    /* ---- undo restores effect stack ---- */
-    const undoCount = await page.evaluate(() => { CS.undo(); return window.__clip ? CS.getClip(window.__clip.id).props.effects.length : -1; });
-    if (undoCount !== 3) fail("undo did not pop effect stack: " + undoCount);
-    await page.evaluate(() => CS.redo());
-    ok("undo/redo covers effects");
-
-    /* ---- titles ---- */
-    await page.evaluate(() => {
-        CS.player.seek(1.5);
-        CS.titles.insertPreset("title");
-        const t = CS.selectedClip();
-        t.props.text.content = "BIG";
-        t.props.text.size = 400;
-        CS.titles.invalidate(t);
-        window.__title = t;
-        CS.player.render();
-    });
-    await page.waitForTimeout(300);
-    const titleInfo = await page.evaluate(() => ({
-        kind: window.__title.kind,
-        track: window.__title.trackId,
-        tracks: CS.project.tracks.filter(t => t.kind === "video").length
-    }));
-    if (titleInfo.kind !== "title") fail("title clip not created");
-    if (titleInfo.track === "V1") fail("title landed on the busy V1 track");
-    const titlePx = await samplePx();
-    if (titlePx[0] < 180 || titlePx[1] < 180 || titlePx[2] < 180) fail("title text not visible at center: " + titlePx);
-    ok(`title clip renders on ${titleInfo.track} (center rgb ${titlePx})`);
-
-    /* ---- elements (color board) ---- */
-    await page.evaluate(() => {
-        CS.deleteSelectedClip(); // remove title again
-        CS.player.seek(10);
-        CS.titles.insertElement("red");
-        CS.player.render();
-    });
-    await page.waitForTimeout(200);
-    const redPx = await samplePx();
-    if (!(redPx[0] > 130 && redPx[1] < 90)) fail("color element not red: " + redPx);
-    await page.evaluate(() => { CS.deleteSelectedClip(); });
-    ok(`color element renders (rgb ${redPx})`);
-
-    /* ---- transitions ---- */
-    await page.evaluate(() => {
-        // second clip right after the first, dissolve into it
-        const c2 = CS.addClipToTimeline(window.__m, "V1", CS.clipEnd(window.__clip));
-        window.__clip2 = c2;
-        CS.selectClip(c2.id);
-        CS.transitions.applyToSelected("dissolve");
-        window.__clip2 = CS.getClip(c2.id);
-    });
-    const trInfo = await page.evaluate(() => ({
-        type: window.__clip2.props.transition && window.__clip2.props.transition.type,
-        frozen: Object.keys(CS.transitions.frozenTargets(window.__clip2.start + 0.3)).length,
-        marker: !!document.querySelector(".tl-clip .clip-tr")
-    }));
-    if (trInfo.type !== "dissolve") fail("transition not stored");
-    if (trInfo.frozen !== 1) fail("frozen predecessor not detected");
-    if (!trInfo.marker) fail("transition marker missing on clip");
-    await page.evaluate(() => CS.player.seek(window.__clip2.start + 0.3));
-    await page.waitForTimeout(600);
-    const trPx = await samplePx();
-    if (trPx[0] + trPx[1] + trPx[2] < 20) fail("transition window renders black: " + trPx);
-    ok(`dissolve transition renders mid-window (rgb ${trPx})`);
-
-    /* ---- filters panel + galleries render ---- */
-    const galleries = await page.evaluate(() => {
-        CS.panels.show("fx");
-        const fx = document.querySelectorAll("#fx-grid .fx-card").length;
-        CS.panels.show("titles");
-        const ti = document.querySelectorAll("#titles-grid .fx-card").length;
-        CS.panels.show("transitions");
-        const tr = document.querySelectorAll("#transitions-grid .fx-card").length;
-        CS.panels.show("elements");
-        const el = document.querySelectorAll("#elements-grid .fx-card").length;
-        CS.panels.show("filters");
-        const fi = document.querySelectorAll("#filters-grid .fx-card").length;
-        CS.panels.show("media");
-        return { fx, ti, tr, el, fi };
-    });
-    if (galleries.fx < 10) fail("effects gallery incomplete: " + galleries.fx);
-    if (galleries.ti < 4 || galleries.tr < 3 || galleries.el < 8 || galleries.fi < 5) {
-        fail("gallery counts wrong: " + JSON.stringify(galleries));
-    }
-    ok(`galleries render (${galleries.fx} effects, ${galleries.ti} titles, ${galleries.tr} transitions, ${galleries.el} elements, ${galleries.fi} filters)`);
-
-    /* ---- filter look via inspector.applyPreset ---- */
-    await page.evaluate(() => {
-        CS.selectClip(window.__clip.id);
-        CS.inspector.applyPreset(CS.getClip(window.__clip.id), "mono");
-    });
-    const preset = await page.evaluate(() => CS.getClip(window.__clip.id).props.preset);
-    if (preset !== "mono") fail("filter preset not applied");
-    ok("filter look applies color preset");
-
-    /* ---- list view toggle ---- */
-    await page.click("#btn-bin-view");
-    const listMode = await page.evaluate(() => document.getElementById("bin-grid").classList.contains("list-mode"));
-    if (!listMode) fail("list view did not activate");
-    await page.click("#btn-bin-view");
-    ok("media bin list view toggles");
-
-    /* ---- serialization round trip with generated clips ---- */
-    const rt = await page.evaluate(() => {
-        CS.player.seek(1.5);
-        CS.titles.insertPreset("lowerthird");
-        const before = CS.project.clips.length;
-        const beforeTitles = CS.project.clips.filter(c => c.kind === "title").length;
-        const json = CS.fileio.serializeProject();
-        CS.fileio.loadProject(JSON.parse(json), "", "rt.cine");
-        return {
-            before, beforeTitles,
-            after: CS.project.clips.length,
-            afterTitles: CS.project.clips.filter(c => c.kind === "title").length,
-            transition: CS.project.clips.some(c => c.props.transition && c.props.transition.type === "dissolve"),
-            effects: CS.project.clips.some(c => (c.props.effects || []).length > 0)
-        };
-    });
-    if (rt.after !== rt.before) fail("clips lost in round trip: " + JSON.stringify(rt));
-    if (rt.afterTitles !== rt.beforeTitles) fail("title clips lost in round trip");
-    if (!rt.transition) fail("transition lost in round trip");
-    if (!rt.effects) fail("effects lost in round trip");
-    ok("round trip preserves titles, transitions and effect stacks");
-});

+ 0 - 187
test/e2e/playwright/specs/functional.js

@@ -1,187 +0,0 @@
-/*
-    Functional test: generate real webm/wav media in the browser, import
-    them into Cine Studio, then exercise playback, editing (split/undo/
-    drag), project serialization round-trip and the WebM export pipeline.
-*/
-"use strict";
-
-const { ok, fail, run } = require("../lib/harness");
-
-run("FUNCTIONAL", async (page, browser) => {
-
-    /* ---- 1. generate + import real media ---- */
-    await page.evaluate(async () => {
-        function makeVideo(name, seconds, hue) {
-            return new Promise((resolve) => {
-                const cv = document.createElement("canvas");
-                cv.width = 320; cv.height = 180;
-                const ctx = cv.getContext("2d");
-                const stream = cv.captureStream(15);
-                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
-                const chunks = [];
-                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
-                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
-                const t0 = performance.now();
-                (function draw() {
-                    const t = (performance.now() - t0) / 1000;
-                    ctx.fillStyle = `hsl(${hue}, 60%, 40%)`;
-                    ctx.fillRect(0, 0, 320, 180);
-                    ctx.fillStyle = "#fff";
-                    ctx.fillRect((t * 60) % 320, 60, 40, 60);
-                    if (t < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
-                })();
-                rec.start(200);
-            });
-        }
-        function makeWav(seconds) {
-            const sr = 22050, n = sr * seconds;
-            const buf = new ArrayBuffer(44 + n * 2);
-            const dv = new DataView(buf);
-            function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
-            ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
-            dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
-            dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
-            dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
-            ws(36, "data"); dv.setUint32(40, n * 2, true);
-            for (let i = 0; i < n; i++) {
-                dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * 330 * i / sr) * 12000, true);
-            }
-            return new Blob([buf], { type: "audio/wav" });
-        }
-
-        const v1 = await makeVideo("ClipA.webm", 3, 200);
-        const v2 = await makeVideo("ClipB.webm", 2.5, 30);
-        window.__testMedia = {
-            v1: CS.media.register({ name: "ClipA.webm", blobUrl: URL.createObjectURL(v1), type: "video" }),
-            v2: CS.media.register({ name: "ClipB.webm", blobUrl: URL.createObjectURL(v2), type: "video" }),
-            a1: CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(makeWav(4)), type: "audio" })
-        };
-    });
-
-    await page.waitForFunction(() => {
-        const m = window.__testMedia;
-        return m.v1.probed && m.v2.probed && m.a1.probed;
-    }, { timeout: 30000 });
-
-    const probe = await page.evaluate(() => {
-        const m = window.__testMedia;
-        return {
-            d1: m.v1.duration, d2: m.v2.duration, da: m.a1.duration,
-            thumbs: m.v1.thumbs.length, peaks: (m.a1.peaks || []).length,
-            offline: m.v1.offline || m.v2.offline || m.a1.offline,
-            binItems: document.querySelectorAll("#bin-grid .bin-item").length
-        };
-    });
-    if (probe.offline) fail("media marked offline");
-    if (!(probe.d1 > 2 && probe.d1 < 4.5)) fail("ClipA duration wrong: " + probe.d1);
-    if (!(probe.da > 3.5 && probe.da < 4.5)) fail("wav duration wrong: " + probe.da);
-    if (probe.thumbs < 1) fail("no video thumbnails");
-    if (probe.peaks < 100) fail("no audio peaks");
-    if (probe.binItems !== 3) fail("bin should show 3 items");
-    ok(`probe: video ${probe.d1.toFixed(2)}s + ${probe.d2.toFixed(2)}s, wav ${probe.da.toFixed(2)}s, ${probe.thumbs} thumbs, ${probe.peaks} peaks`);
-
-    /* ---- 2. build a timeline ---- */
-    await page.evaluate(() => {
-        const m = window.__testMedia;
-        const c1 = CS.addClipToTimeline(m.v1, "V1", 0);
-        CS.addClipToTimeline(m.v2, "V1", CS.clipDuration(c1));
-        CS.addClipToTimeline(m.a1, "A1", 0);
-        CS.commit("Test Build");
-    });
-    const clipCount = await page.evaluate(() => CS.project.clips.length);
-    if (clipCount !== 3) fail("expected 3 clips, got " + clipCount);
-    ok("3 clips placed on timeline");
-
-    /* ---- 3. playback ---- */
-    await page.click("#btn-play");
-    await page.waitForTimeout(1600);
-    const playState = await page.evaluate(() => {
-        const cv = document.getElementById("preview-canvas");
-        const ctx = cv.getContext("2d");
-        const px = ctx.getImageData(Math.floor(cv.width / 2), Math.floor(cv.height / 2), 1, 1).data;
-        return { playhead: CS.state.playhead, playing: CS.state.playing, px: Array.from(px) };
-    });
-    if (!playState.playing) fail("player is not playing");
-    if (playState.playhead < 1.0) fail("playhead did not advance: " + playState.playhead);
-    if (playState.px[0] + playState.px[1] + playState.px[2] < 20) fail("preview canvas looks black during playback");
-    ok(`playback: playhead=${playState.playhead.toFixed(2)}s, center pixel rgb(${playState.px.slice(0, 3)})`);
-    await page.click("#btn-play"); // pause
-
-    /* ---- 4. split / undo / redo ---- */
-    const split = await page.evaluate(() => {
-        CS.player.seek(1.2);
-        CS.selectClip(CS.project.clips[0].id);
-        const before = CS.project.clips.length;
-        CS.splitAtPlayhead();
-        const after = CS.project.clips.length;
-        CS.undo();
-        const undone = CS.project.clips.length;
-        CS.redo();
-        const redone = CS.project.clips.length;
-        return { before, after, undone, redone };
-    });
-    if (split.after !== split.before + 1) fail("split did not add a clip");
-    if (split.undone !== split.before) fail("undo did not restore");
-    if (split.redone !== split.before + 1) fail("redo did not reapply");
-    ok(`split/undo/redo: ${split.before} -> ${split.after} -> ${split.undone} -> ${split.redone}`);
-
-    /* ---- 5. drag a clip with the mouse ---- */
-    const dragged = await page.evaluate(() => {
-        window.__dragTarget = CS.project.clips.find(c => c.trackId === "A1");
-        return { id: window.__dragTarget.id, start: window.__dragTarget.start };
-    });
-    const box = await page.locator(`.tl-clip[data-clip-id="${dragged.id}"]`).boundingBox();
-    if (!box) fail("audio clip element not found");
-    await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
-    await page.mouse.down();
-    await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2, { steps: 8 });
-    await page.mouse.up();
-    const newStart = await page.evaluate(() => CS.getClip(window.__dragTarget.id).start);
-    if (Math.abs(newStart - dragged.start) < 0.5) fail("mouse drag did not move the clip: " + newStart);
-    ok(`mouse drag moved audio clip ${dragged.start.toFixed(2)}s -> ${newStart.toFixed(2)}s`);
-
-    /* ---- 6. project serialization round trip ---- */
-    const roundTrip = await page.evaluate(() => {
-        const json = CS.fileio.serializeProject();
-        const beforeClips = CS.project.clips.length;
-        const beforeTracks = CS.project.tracks.length;
-        CS.fileio.loadProject(JSON.parse(json), "", "roundtrip.cine");
-        return {
-            beforeClips, beforeTracks,
-            afterClips: CS.project.clips.length,
-            afterTracks: CS.project.tracks.length,
-            name: CS.project.name,
-            parses: !!json.length
-        };
-    });
-    if (roundTrip.afterClips !== roundTrip.beforeClips) fail("clips lost in round trip");
-    if (roundTrip.afterTracks !== roundTrip.beforeTracks) fail("tracks lost in round trip");
-    ok(`project round trip: ${roundTrip.afterClips} clips, ${roundTrip.afterTracks} tracks preserved`);
-
-    /* ---- 7. export to WebM (fresh short timeline) ---- */
-    await page.evaluate(() => {
-        const m = window.__testMedia;
-        CS.newProject({ name: "ExportTest", width: 640, height: 360, fps: 30 });
-        CS.player.applyProjectSize();
-        // media pool was cleared by newProject: re-register the blobs
-        const v = CS.media.register({ name: m.v1.name, blobUrl: m.v1.blobUrl, type: "video" });
-        const a = CS.media.register({ name: m.a1.name, blobUrl: m.a1.blobUrl, type: "audio" });
-        window.__exportSeed = { v, a };
-    });
-    await page.waitForFunction(() => window.__exportSeed.v.probed && window.__exportSeed.a.probed, { timeout: 20000 });
-    await page.evaluate(() => {
-        const s = window.__exportSeed;
-        const c = CS.addClipToTimeline(s.v, "V1", 0);
-        c.out = Math.min(c.out, 2.5);
-        const ca = CS.addClipToTimeline(s.a, "A1", 0);
-        ca.out = Math.min(ca.out, 2.5);
-        CS.commit("Export Seed");
-        window.__exportSize = -1;
-        CS.fileio.downloadBlob = function (blob) { window.__exportSize = blob.size; };
-        CS.exporter.start({ base: "test_export", format: "webm", destDir: "", toDevice: true });
-    });
-    await page.waitForFunction(() => window.__exportSize >= 0, { timeout: 30000 });
-    const exportSize = await page.evaluate(() => window.__exportSize);
-    if (exportSize < 5000) fail("export produced a suspiciously small file: " + exportSize);
-    ok(`export produced WebM of ${exportSize} bytes`);
-});

+ 0 - 236
test/e2e/playwright/specs/interaction.js

@@ -1,236 +0,0 @@
-/*
-    Iteration 3 tests: preview drag/resize overlay, auto track creation,
-    Pixel Studio (.pxs) and Audio Studio (.asproj) imports.
-*/
-"use strict";
-
-const { ok, fail, run } = require("../lib/harness");
-
-run("INTERACTION", async (page, browser) => {
-
-    /* ---- seed one real video + audio ---- */
-    await page.evaluate(async () => {
-        function makeVideo(seconds, hue) {
-            return new Promise((resolve) => {
-                const cv = document.createElement("canvas");
-                cv.width = 320; cv.height = 180;
-                const ctx = cv.getContext("2d");
-                const stream = cv.captureStream(15);
-                const rec = new MediaRecorder(stream, { mimeType: "video/webm" });
-                const chunks = [];
-                rec.ondataavailable = e => e.data.size && chunks.push(e.data);
-                rec.onstop = () => resolve(new Blob(chunks, { type: "video/webm" }));
-                const t0 = performance.now();
-                (function draw() {
-                    ctx.fillStyle = `hsl(${hue}, 65%, 45%)`;
-                    ctx.fillRect(0, 0, 320, 180);
-                    if ((performance.now() - t0) / 1000 < seconds) { requestAnimationFrame(draw); } else { rec.stop(); }
-                })();
-                rec.start(200);
-            });
-        }
-        window.makeWavDataUrl = function (seconds, freq) {
-            const sr = 22050, n = Math.floor(sr * seconds);
-            const buf = new ArrayBuffer(44 + n * 2);
-            const dv = new DataView(buf);
-            function ws(o, s) { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }
-            ws(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); ws(8, "WAVEfmt ");
-            dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
-            dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true);
-            dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
-            ws(36, "data"); dv.setUint32(40, n * 2, true);
-            for (let i = 0; i < n; i++) {
-                dv.setInt16(44 + i * 2, Math.sin(2 * Math.PI * freq * i / sr) * 12000, true);
-            }
-            let bin = "";
-            const u8 = new Uint8Array(buf);
-            for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
-            return "data:audio/wav;base64," + btoa(bin);
-        };
-        const v = await makeVideo(3, 210);
-        window.__vid = CS.media.register({ name: "Base.webm", blobUrl: URL.createObjectURL(v), type: "video" });
-        const wavResp = await fetch(window.makeWavDataUrl(3, 220));
-        window.__aud = CS.media.register({ name: "Tone.wav", blobUrl: URL.createObjectURL(await wavResp.blob()), type: "audio" });
-    });
-    await page.waitForFunction(() => window.__vid.probed && window.__aud.probed, { timeout: 30000 });
-
-    await page.evaluate(() => {
-        window.__vclip = CS.addClipToTimeline(window.__vid, "V1", 0);
-        window.__aclip = CS.addClipToTimeline(window.__aud, "A1", 0);
-        CS.commit("seed");
-        CS.selectClip(window.__vclip.id);
-        CS.player.seek(1.0);
-    });
-    await page.waitForTimeout(600);
-
-    /* ---- 1. preview overlay shows for selected clip ---- */
-    const overlayBox = await page.evaluate(() => {
-        const box = CS.previewctl.clipBox(window.__vclip);
-        const ov = document.getElementById("preview-overlay");
-        return { box: !!box, w: ov.clientWidth, h: ov.clientHeight };
-    });
-    if (!overlayBox.box || overlayBox.w < 100) fail("overlay not sized/positioned: " + JSON.stringify(overlayBox));
-    ok("preview overlay active over canvas");
-
-    /* ---- 2. drag clip in preview moves it ---- */
-    const ovEl = page.locator("#preview-overlay");
-    const ovRect = await ovEl.boundingBox();
-    const cx = ovRect.x + ovRect.width / 2;
-    const cy = ovRect.y + ovRect.height / 2;
-    await page.mouse.move(cx, cy);
-    await page.mouse.down();
-    await page.mouse.move(cx + 60, cy + 30, { steps: 6 });
-    await page.mouse.up();
-    const moved = await page.evaluate(() => ({ x: window.__vclip.props.x, y: window.__vclip.props.y }));
-    if (moved.x < 30 || moved.y < 15) fail("preview drag did not move clip: " + JSON.stringify(moved));
-    ok(`preview drag moved clip to x=${moved.x}, y=${moved.y}`);
-
-    /* ---- 3. corner handle resize changes scale ---- */
-    await page.evaluate(() => {
-        // bring the clip back fully on-screen so its handles are grabbable
-        window.__vclip.props.x = 0;
-        window.__vclip.props.y = 0;
-        window.__vclip.props.scale = 60;
-        CS.player.render();
-    });
-    const corner = await page.evaluate(() => {
-        const box = CS.previewctl.clipBox(window.__vclip);
-        const pts = CS.previewctl.corners(box);
-        const rect = CS.player.canvas.getBoundingClientRect();
-        const s = rect.width / CS.project.settings.width;
-        return { x: rect.left + pts[2].x * s, y: rect.top + pts[2].y * s };
-    });
-    await page.mouse.move(corner.x, corner.y);
-    await page.mouse.down();
-    await page.mouse.move(corner.x - 80, corner.y - 45, { steps: 6 });
-    await page.mouse.up();
-    const scaled = await page.evaluate(() => window.__vclip.props.scale);
-    if (scaled >= 60) fail("corner drag did not shrink clip: scale=" + scaled);
-    ok(`corner resize scaled clip to ${scaled}%`);
-
-    /* ---- 4. click other clip in preview selects it, empty deselects ---- */
-    await page.evaluate(() => {
-        window.__vclip.props.x = 0; window.__vclip.props.y = 0; window.__vclip.props.scale = 40;
-        CS.player.render();
-    });
-    await page.mouse.click(ovRect.x + 20, ovRect.y + 20); // outside 40% box
-    const sel = await page.evaluate(() => CS.state.selectedClipId);
-    if (sel !== null) fail("click on empty preview did not deselect: " + sel);
-    await page.mouse.click(cx, cy); // inside box again
-    const sel2 = await page.evaluate(() => CS.state.selectedClipId);
-    if (sel2 !== (await page.evaluate(() => window.__vclip.id))) fail("click on clip did not select it");
-    ok("preview click selects / deselects clips");
-
-    /* ---- 5. dragging audio clip below lanes creates a new audio track ---- */
-    const beforeTracks = await page.evaluate(() => CS.project.tracks.length);
-    const aBox = await page.locator(`.tl-clip[data-clip-id="${await page.evaluate(() => window.__aclip.id)}"]`).boundingBox();
-    const tlRect = await page.locator("#tl-tracks").boundingBox();
-    await page.mouse.move(aBox.x + aBox.width / 2, aBox.y + aBox.height / 2);
-    await page.mouse.down();
-    await page.mouse.move(aBox.x + aBox.width / 2 + 10, tlRect.y + tlRect.height + 30, { steps: 6 });
-    await page.mouse.up();
-    const trackInfo = await page.evaluate(() => ({
-        n: CS.project.tracks.length,
-        clipTrack: CS.getClip(window.__aclip.id).trackId,
-        audioTracks: CS.project.tracks.filter(t => t.kind === "audio").length
-    }));
-    if (trackInfo.n !== beforeTracks + 1) fail("no new track created: " + JSON.stringify(trackInfo));
-    if (trackInfo.clipTrack !== "A3") fail("clip not moved to new track: " + trackInfo.clipTrack);
-    ok(`drag below lanes created ${trackInfo.clipTrack} (${trackInfo.audioTracks} audio tracks now)`);
-
-    /* ---- 6. .pxs import (raster + text layers, blend/opacity) ---- */
-    await page.evaluate(() => {
-        const lc = document.createElement("canvas");
-        lc.width = 400; lc.height = 300;
-        const lctx = lc.getContext("2d");
-        lctx.fillStyle = "#c62828";
-        lctx.fillRect(0, 0, 400, 300);
-        const pxs = {
-            app: "PixelStudio", version: 1, width: 400, height: 300, active: 0,
-            layers: [
-                { name: "bg", visible: true, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") },
-                { name: "txt", visible: true, opacity: 1, blend: "source-over", type: "text",
-                  text: { content: "Hi", x: 20, y: 20, size: 60, color: "#ffffff", font: "Arial", bold: true, italic: false } },
-                { name: "hidden", visible: false, opacity: 1, blend: "source-over", type: "raster", data: lc.toDataURL("image/png") }
-            ]
-        };
-        const blob = new Blob([JSON.stringify(pxs)], { type: "application/json" });
-        window.__pxs = CS.media.register({
-            name: "Poster.pxs", blobUrl: URL.createObjectURL(blob), type: "image", srcKind: "pxs"
-        });
-    });
-    await page.waitForFunction(() => window.__pxs.probed, { timeout: 15000 });
-    const pxsInfo = await page.evaluate(() => ({
-        offline: window.__pxs.offline, w: window.__pxs.width, h: window.__pxs.height,
-        thumbs: window.__pxs.thumbs.length, composite: !!window.__pxs.compositeUrl
-    }));
-    if (pxsInfo.offline) fail(".pxs marked offline");
-    if (pxsInfo.w !== 400 || pxsInfo.h !== 300) fail(".pxs dimensions wrong: " + JSON.stringify(pxsInfo));
-    if (!pxsInfo.thumbs || !pxsInfo.composite) fail(".pxs not composited");
-    // place on timeline and verify the red pixel shows
-    await page.evaluate(() => {
-        CS.project.clips = CS.project.clips.filter(c => c.trackId !== "V1");
-        const c = CS.addClipToTimeline(window.__pxs, "V1", 0);
-        c.props.crop = "fill";
-        CS.commit("pxs clip");
-        CS.player.seek(0.5);
-    });
-    await page.waitForTimeout(400);
-    const pxsPx = await page.evaluate(() => {
-        const cv = document.getElementById("preview-canvas");
-        const d = cv.getContext("2d").getImageData(Math.floor(cv.width * 0.7), Math.floor(cv.height * 0.7), 1, 1).data;
-        return [d[0], d[1], d[2]];
-    });
-    if (!(pxsPx[0] > 140 && pxsPx[1] < 90)) fail(".pxs frame not red: " + pxsPx);
-    ok(`.pxs imported, composited 400x300, renders red (rgb ${pxsPx})`);
-
-    /* ---- 7. .asproj with rendered mixdown ---- */
-    await page.evaluate(() => {
-        const asproj = { app: "AudioStudio", version: 1, name: "Mix", mixdown: window.makeWavDataUrl(2, 440) };
-        const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
-        window.__as1 = CS.media.register({
-            name: "Song.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
-        });
-    });
-    await page.waitForFunction(() => window.__as1.probed, { timeout: 15000 });
-    const as1 = await page.evaluate(() => ({
-        offline: window.__as1.offline, dur: window.__as1.duration, peaks: (window.__as1.peaks || []).length
-    }));
-    if (as1.offline) fail("mixdown .asproj offline");
-    if (!(as1.dur > 1.8 && as1.dur < 2.2)) fail("mixdown duration wrong: " + as1.dur);
-    ok(`.asproj mixdown imported (${as1.dur.toFixed(2)}s, ${as1.peaks} peaks)`);
-
-    /* ---- 8. .asproj with tracks/clips mixed offline ---- */
-    await page.evaluate(() => {
-        const asproj = {
-            app: "AudioStudio", version: 1,
-            tracks: [
-                { name: "T1", volume: 1, clips: [{ src: window.makeWavDataUrl(1.5, 330), start: 0 }] },
-                { name: "T2", volume: 0.6, clips: [{ src: window.makeWavDataUrl(1.5, 550), start: 1.0 }] }
-            ]
-        };
-        const blob = new Blob([JSON.stringify(asproj)], { type: "application/json" });
-        window.__as2 = CS.media.register({
-            name: "Session.asproj", blobUrl: URL.createObjectURL(blob), type: "audio", srcKind: "asproj"
-        });
-    });
-    await page.waitForFunction(() => window.__as2.probed, { timeout: 20000 });
-    const as2 = await page.evaluate(() => ({
-        offline: window.__as2.offline, dur: window.__as2.duration, peaks: (window.__as2.peaks || []).length
-    }));
-    if (as2.offline) fail("track-mix .asproj offline");
-    if (!(as2.dur > 2.3 && as2.dur < 2.7)) fail("mixed duration wrong (want ~2.5): " + as2.dur);
-    ok(`.asproj track mix rendered offline (${as2.dur.toFixed(2)}s, ${as2.peaks} peaks)`);
-
-    /* ---- 9. audio playback of imported asproj on the timeline ---- */
-    await page.evaluate(() => {
-        CS.addClipToTimeline(window.__as1, "A1", 0);
-        CS.commit("asproj clip");
-    });
-    await page.click("#btn-play");
-    await page.waitForTimeout(900);
-    const playing = await page.evaluate(() => CS.state.playing && CS.state.playhead > 0.4);
-    if (!playing) fail("timeline with imported media does not play");
-    await page.click("#btn-play");
-    ok("imported project media plays on the timeline");
-});