Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
/**
|
|
* Guards for scripts/set-version.sh — the release version stamper.
|
|
*
|
|
* TRACES: DR-153 | UT-150
|
|
*
|
|
* These run the real script against a throwaway copy of the manifests, because
|
|
* the failure modes are all in the shell, not in any TS logic: a regex that also
|
|
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
|
|
* a CI ref reaching the validator verbatim.
|
|
*
|
|
* The versionCode formula matters most. Android refuses an update whose code is
|
|
* lower than the installed one, and builds already in the field shipped code
|
|
* 1000 — so any formula that can emit a smaller number for a *newer* release
|
|
* bricks updates for those users, silently and irreversibly.
|
|
*/
|
|
|
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
import { execFileSync } from "child_process";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as os from "os";
|
|
|
|
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
const script = path.join(repoRoot, "scripts", "set-version.sh");
|
|
|
|
let tmp: string;
|
|
|
|
/** A minimal repo skeleton: just the files the script rewrites. */
|
|
function seed(dir: string) {
|
|
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
|
|
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
|
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
|
|
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
|
|
|
|
fs.writeFileSync(
|
|
path.join(dir, "package.json"),
|
|
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, "src-tauri", "tauri.conf.json"),
|
|
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
|
);
|
|
// A dependency carrying its own `version =` is the trap: a greedy regex
|
|
// rewrites it too and the build then resolves the wrong crate.
|
|
fs.writeFileSync(
|
|
path.join(dir, "src-tauri", "Cargo.toml"),
|
|
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, "src-tauri", "Cargo.lock"),
|
|
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
|
"tauri.android.versionCode=1\n"
|
|
);
|
|
}
|
|
|
|
function run(version: string, dir = tmp) {
|
|
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
|
|
cwd: dir,
|
|
encoding: "utf-8",
|
|
});
|
|
}
|
|
|
|
function read(rel: string): string {
|
|
return fs.readFileSync(path.join(tmp, rel), "utf-8");
|
|
}
|
|
|
|
function versionCode(): number {
|
|
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
|
/^tauri\.android\.versionCode=(\d+)$/m
|
|
);
|
|
return m ? Number(m[1]) : NaN;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
|
|
seed(tmp);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("set-version.sh", () => {
|
|
it("stamps the version into all four manifests", () => {
|
|
run("0.5.0");
|
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
|
|
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
|
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
|
|
});
|
|
|
|
it("accepts a leading v, as git tags are written", () => {
|
|
run("v0.5.0");
|
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
|
});
|
|
|
|
// The regression that motivates anchoring the patterns.
|
|
it("does not rewrite dependency versions", () => {
|
|
run("0.5.0");
|
|
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
|
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
|
|
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
|
|
});
|
|
|
|
describe("Android versionCode", () => {
|
|
// A newer release must never produce a smaller number than an older one, or
|
|
// Android refuses the update. The floor tracks the highest code actually in
|
|
// the field, which is NOT the same as the highest this formula has produced:
|
|
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
|
// the `minor*100` formula that replaced it yields only 1502 for that same
|
|
// version — so every 0.5.x release built from it was an un-installable
|
|
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
|
it("clears the highest code shipped by earlier builds", () => {
|
|
run("0.0.1");
|
|
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
|
expect(versionCode()).toBeGreaterThan(5002);
|
|
});
|
|
|
|
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
|
run("0.5.3");
|
|
expect(versionCode()).toBeGreaterThan(5002);
|
|
});
|
|
|
|
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
|
const cases: Array<[string, number]> = [
|
|
["0.0.14", 10014],
|
|
["0.0.15", 10015],
|
|
["0.1.0", 11000],
|
|
["0.4.8", 14008],
|
|
["0.5.0", 15000],
|
|
["0.5.3", 15003],
|
|
["1.0.0", 1010000],
|
|
];
|
|
for (const [version, code] of cases) {
|
|
seed(tmp);
|
|
run(version);
|
|
expect(versionCode(), `versionCode for ${version}`).toBe(code);
|
|
}
|
|
});
|
|
|
|
it("increases monotonically across an upgrade sequence", () => {
|
|
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
|
|
const codes = ordered.map((v) => {
|
|
seed(tmp);
|
|
run(v);
|
|
return versionCode();
|
|
});
|
|
const sorted = [...codes].sort((a, b) => a - b);
|
|
expect(codes).toEqual(sorted);
|
|
expect(new Set(codes).size).toBe(codes.length);
|
|
});
|
|
|
|
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
|
|
// stripped before the arithmetic.
|
|
it("derives the code from the numeric core of a prerelease", () => {
|
|
run("0.6.0-rc1");
|
|
expect(versionCode()).toBe(16000);
|
|
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
|
});
|
|
});
|
|
|
|
describe("input validation", () => {
|
|
it("rejects a malformed version without writing anything", () => {
|
|
expect(() => run("not-a-version")).toThrow();
|
|
// The manifests must be untouched, not half-written.
|
|
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
|
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
|
|
});
|
|
|
|
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
|
|
// that is still a full ref, and must not fail the job.
|
|
it("falls back to a dev version when handed a non-tag ref", () => {
|
|
const out = run("refs/heads/master");
|
|
expect(out).not.toMatch(/refs\/heads/);
|
|
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
|
});
|
|
});
|
|
});
|