Files
punktfunk/packaging/winget/server/test.mjs
T
enricobuehlerandClaude Opus 5 1839d7566b
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m56s
apple / screenshots (push) Canceled after 11m30s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 6m2s
ci / web (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m29s
ci / rust-arm64 (push) Successful in 9m56s
android / android (push) Successful in 12m33s
ci / bench (push) Successful in 7m17s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
arch / build-publish (push) Successful in 18m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m2s
ci / rust (push) Failing after 19m38s
deb / build-publish-client-arm64 (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 9m43s
feat(packaging/winget): install the Windows host with winget, from our own source
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.

Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.

Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.

Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.

It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.

NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.

28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00

123 lines
6.5 KiB
JavaScript

// Drives the Worker's handle() directly — no Workers runtime, no network, no deploy.
//
// This is the gate that catches a broken source BEFORE winget sees it. A source that returns the
// wrong shape does not error loudly: winget reports "no package found" and the failure looks like a
// missing package rather than a malformed response.
//
// node test.mjs (run `npm run build:local` first so data/data.json exists)
import { readFileSync } from "node:fs";
import { handle } from "./handler.mjs";
// The catalogue is an argument, not an import — same way server.mjs passes it — so this suite runs
// against a generated data.json with no server, no network and no container.
const DATA = JSON.parse(readFileSync(new URL("./data/data.json", import.meta.url), "utf8"));
const BASE = "https://winget.example/";
let failed = 0;
function check(name, cond, detail = "") {
if (cond) {
console.log(`ok ${name}`);
} else {
failed++;
console.log(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
}
}
const get = (p) => handle(new Request(BASE + p, { method: "GET" }), DATA);
const post = (p, body) =>
handle(new Request(BASE + p, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }), DATA);
// ── /information ────────────────────────────────────────────────────────────────────────────────
{
const res = await get("information");
const body = await res.json();
check("information: 200", res.status === 200, `got ${res.status}`);
check("information: wrapped in Data", !!body.Data);
check("information: has SourceIdentifier", typeof body.Data?.SourceIdentifier === "string");
check("information: advertises a version", Array.isArray(body.Data?.ServerSupportedVersions) && body.Data.ServerSupportedVersions.length > 0);
check(
"information: declares NormalizedPackageNameAndPublisher unsupported",
body.Data?.UnsupportedPackageMatchFields?.includes("NormalizedPackageNameAndPublisher"),
);
}
// ── /manifestSearch ─────────────────────────────────────────────────────────────────────────────
{
const res = await post("manifestSearch", { Query: { KeyWord: "punktfunk", MatchType: "Substring" } });
const body = await res.json();
check("search substring 'punktfunk': 200", res.status === 200, `got ${res.status}`);
check("search: Data is a non-empty array", Array.isArray(body.Data) && body.Data.length > 0);
const hit = body.Data?.[0];
check("search: PackageIdentifier present", hit?.PackageIdentifier === "unom.PunktfunkHost");
check("search: PackageName + Publisher present (schema-required)", !!hit?.PackageName && !!hit?.Publisher);
check("search: Versions non-empty (minItems 1)", Array.isArray(hit?.Versions) && hit.Versions.length > 0);
check("search: version carries ProductCodes for ARP correlation", Array.isArray(hit?.Versions?.[0]?.ProductCodes) && hit.Versions[0].ProductCodes.length > 0);
}
{
const res = await post("manifestSearch", { Query: { KeyWord: "definitely-not-a-package", MatchType: "Substring" } });
check("search miss: 204 No Content, not an empty 200", res.status === 204, `got ${res.status}`);
}
{
const res = await post("manifestSearch", { FetchAllManifests: true });
const body = await res.json();
check("FetchAllManifests returns everything", res.status === 200 && body.Data.length >= 1);
}
{
const res = await post("manifestSearch", {
Inclusions: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "unom.PunktfunkHost", MatchType: "CaseInsensitive" } }],
});
check("Inclusions on PackageIdentifier match", res.status === 200);
}
{
// A filter that cannot hold must exclude the package (Filters are AND).
const res = await post("manifestSearch", {
Filters: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "some.OtherPackage", MatchType: "Exact" } }],
});
check("non-matching Filter excludes (AND semantics)", res.status === 204, `got ${res.status}`);
}
{
// An unsupported field must never accidentally match — we told the client we don't serve it.
const res = await post("manifestSearch", {
Filters: [{ PackageMatchField: "NormalizedPackageNameAndPublisher", RequestMatch: { KeyWord: "punktfunkhostunom", MatchType: "Exact" } }],
});
check("unsupported match field yields no match", res.status === 204, `got ${res.status}`);
}
{
const res = await handle(new Request(BASE + "manifestSearch", { method: "GET" }), DATA);
check("manifestSearch rejects GET", res.status === 405, `got ${res.status}`);
}
// ── /packageManifests/{id} ──────────────────────────────────────────────────────────────────────
{
const res = await get("packageManifests/unom.PunktfunkHost");
const body = await res.json();
check("manifest: 200", res.status === 200, `got ${res.status}`);
const v = body.Data?.Versions?.[0];
check("manifest: Versions present", !!v);
check("manifest: version has DefaultLocale", !!v?.DefaultLocale?.PackageName);
check("manifest: version has Installers", Array.isArray(v?.Installers) && v.Installers.length > 0);
const inst = v?.Installers?.[0];
check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? ""));
check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine");
check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length);
}
{
const res = await get("packageManifests/UNOM.PUNKTFUNKHOST");
check("manifest: PackageIdentifier is case-insensitive", res.status === 200, `got ${res.status}`);
}
{
const res = await get("packageManifests/nope.NotHere");
check("manifest: unknown id -> 404", res.status === 404, `got ${res.status}`);
}
{
const res = await get("packageManifests/unom.PunktfunkHost?Version=0.0.0-nope");
check("manifest: unknown ?Version -> 404", res.status === 404, `got ${res.status}`);
}
{
const res = await get("no/such/route");
check("unknown route -> 404", res.status === 404, `got ${res.status}`);
}
console.log(failed === 0 ? "\nall checks passed" : `\n${failed} CHECK(S) FAILED`);
process.exit(failed === 0 ? 0 : 1);