Fix release updater smoke recovery (#2687)

This commit is contained in:
PerishFire 2026-05-22 16:18:33 +08:00 committed by GitHub
parent b4e94b0534
commit af66a929bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 157 additions and 6 deletions

View file

@ -198,6 +198,8 @@ type LoadedRelease = {
ref: UpdateReleaseRef;
};
type ResolvedChecksumSnapshot = DesktopUpdateChecksumSnapshot & { value: string };
type OwnedRoot =
| { metadataPath: string; ok: true; realRoot: string }
| { error: DesktopUpdateErrorSnapshot; ok: false };
@ -511,6 +513,10 @@ function isChecksumSnapshot(value: unknown): value is DesktopUpdateChecksumSnaps
return true;
}
function isResolvedChecksumSnapshot(value: unknown): value is ResolvedChecksumSnapshot {
return isChecksumSnapshot(value) && typeof value.value === "string" && value.value.length > 0;
}
function isUpdateReleaseRef(value: unknown): value is UpdateReleaseRef {
if (!isRecord(value)) return false;
return stringField(value, "arch") != null &&
@ -1343,6 +1349,60 @@ async function loadActiveRelease(
return { ok: true, active: { path: artifactPath, ref: active } };
}
function checksumMatchesCandidate(checksum: ResolvedChecksumSnapshot, candidate: UpdateCandidate): boolean {
if (checksum.algorithm !== candidate.checksum.algorithm) return false;
if (candidate.checksum.url != null && checksum.url !== candidate.checksum.url) return false;
if (candidate.checksum.value != null && checksum.value.toLowerCase() !== candidate.checksum.value.toLowerCase()) return false;
return true;
}
async function loadVerifiedReleaseForCandidate(
root: OwnedRoot & { ok: true },
candidate: UpdateCandidate,
): Promise<LoadedRelease | null> {
const releasesRoot = resolve(root.realRoot, RELEASES_DIR);
const entries = await readdir(releasesRoot, { withFileTypes: true }).catch(() => []);
const outputName = artifactFileName(candidate);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const releaseDir = resolve(releasesRoot, entry.name);
if (!containsPath(root.realRoot, releaseDir)) continue;
const checksum = await readJson<unknown>(join(releaseDir, "checksum.json"));
if (!isResolvedChecksumSnapshot(checksum) || !checksumMatchesCandidate(checksum, candidate)) continue;
if (entry.name !== releaseKey(candidate, checksum)) continue;
const metadata = await readJson<unknown>(join(releaseDir, "metadata.json"));
if (!isRecord(metadata)) continue;
const artifactPath = resolve(releaseDir, outputName);
if (!containsPath(root.realRoot, artifactPath)) continue;
const artifactStat = await stat(artifactPath).catch(() => null);
if (artifactStat == null || !artifactStat.isFile()) continue;
const digest = await hashFile(artifactPath, checksum.algorithm).catch(() => null);
if (digest?.toLowerCase() !== checksum.value.toLowerCase()) continue;
const ref: UpdateReleaseRef = {
arch: candidate.arch,
artifact: candidate.artifact,
artifactPath: relative(root.realRoot, artifactPath),
checksum,
checksumPath: relative(root.realRoot, join(releaseDir, "checksum.json")),
channel: candidate.channel,
downloadedAt: artifactStat.mtime.toISOString(),
key: entry.name,
metadata,
metadataPath: relative(root.realRoot, join(releaseDir, "metadata.json")),
platformKey: candidate.platformKey,
version: candidate.version,
};
return { path: artifactPath, ref };
}
return null;
}
export function createDesktopUpdater(
configInput: DesktopUpdaterConfigInput,
deps: DesktopUpdaterDeps = {},
@ -1535,6 +1595,29 @@ export function createDesktopUpdater(
metadata = selected.candidate.metadata;
return setState(DESKTOP_UPDATE_STATES.DOWNLOADED);
}
const openedForAdoption = await openStore();
if (openedForAdoption.ok) {
const adoptedRelease = await loadVerifiedReleaseForCandidate(openedForAdoption.root, selected.candidate);
if (adoptedRelease != null) {
candidate = selected.candidate;
activeRelease = adoptedRelease;
metadata = adoptedRelease.ref.metadata;
installFrozen = false;
installResult = undefined;
incomingRelease = null;
progress = undefined;
await writeStoreMetadata(openedForAdoption.root, {
...openedForAdoption.metadata,
active: adoptedRelease.ref,
incoming: undefined,
installFrozen: false,
installResult: undefined,
lastCheckedAt,
version: STORE_METADATA_VERSION,
});
return setState(DESKTOP_UPDATE_STATES.DOWNLOADED);
}
}
candidate = selected.candidate;
const available = activeRelease == null
? setState(DESKTOP_UPDATE_STATES.AVAILABLE)

View file

@ -628,6 +628,47 @@ describe("desktop updater", () => {
}
});
it("adopts a verified release directory when active metadata is missing", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const first = await updater.checkForUpdates();
expect(first.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(first.downloadPath).toEqual(expect.any(String));
expect(fixture.artifactRequests()).toBe(1);
await writeFile(join(root, "metadata.json"), JSON.stringify({
lastCheckedAt: first.lastCheckedAt,
version: 1,
}), "utf8");
const restarted = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const restored = await restarted.checkForUpdates();
const metadata = JSON.parse(await readFile(join(root, "metadata.json"), "utf8")) as Record<string, unknown>;
expect(restored.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(restored.downloadPath).toBe(first.downloadPath);
expect(restored.active?.path).toBe(first.downloadPath);
expect(metadata.active).toEqual(expect.any(Object));
expect(fixture.artifactRequests()).toBe(1);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("reports old flat updater stores as protocol errors without repairing them", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();

View file

@ -64,6 +64,15 @@ const clickUpdaterInstallExpression = `
return { clicked: true };
})()
`;
const clickUpdaterRailExpression = `
(() => {
const button = document.querySelector('[data-testid="entry-nav-updater"]');
if (!(button instanceof HTMLButtonElement)) return { clicked: false, reason: 'missing-updater-rail' };
if (button.getAttribute('aria-disabled') === 'true') return { clicked: false, reason: 'updater-rail-disabled' };
button.click();
return { clicked: true };
})()
`;
type DesktopStatus = {
state?: string;
@ -224,9 +233,10 @@ macDescribe('packaged mac runtime smoke', () => {
expect(value.health.version).toEqual(expect.any(String));
}
const popup = await waitForUpdaterPopup();
const popup = await openReadyUpdaterPrompt(updaterFixture.info.version);
expect(popup.visible).toBe(true);
expect(popup.title).toBe('Update ready');
expect(popup.title).toEqual(expect.any(String));
expect(popup.title?.trim().length).toBeGreaterThan(0);
expect(popup.installButtonVisible).toBe(true);
expect(popup.text ?? '').toContain(updaterFixture.info.version);
@ -1662,8 +1672,25 @@ async function waitForHealthyDesktop(): Promise<MacInspectResult> {
throw new Error(`packaged mac runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
async function waitForUpdaterPopup(): Promise<UpdaterPopupEvalValue> {
const timeoutMs = 90_000;
async function openReadyUpdaterPrompt(version: string): Promise<UpdaterPopupEvalValue> {
await clickUpdaterRailButton('open ready updater prompt');
return await waitForUpdaterPopupMatching(
(popup) => popup.visible && popup.installButtonVisible && (popup.text ?? '').includes(version),
'ready updater prompt',
);
}
async function clickUpdaterRailButton(label: string): Promise<void> {
const click = await runToolsPackJson<MacInspectResult>('inspect', ['--expr', clickUpdaterRailExpression]);
const value = assertUpdaterClickEvalValue(click.eval?.value);
expect(value.clicked, `${label}: ${value.reason ?? 'updater rail not clicked'}`).toBe(true);
}
async function waitForUpdaterPopupMatching(
predicate: (value: UpdaterPopupEvalValue) => boolean,
label: string,
timeoutMs = 90_000,
): Promise<UpdaterPopupEvalValue> {
const startedAt = Date.now();
let lastResult: unknown = null;
@ -1673,7 +1700,7 @@ async function waitForUpdaterPopup(): Promise<UpdaterPopupEvalValue> {
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asUpdaterPopupEvalValue(inspect.eval.value);
if (value?.visible === true && value.installButtonVisible === true) return value;
if (value != null && predicate(value)) return value;
}
} catch (error) {
lastResult = error;
@ -1681,7 +1708,7 @@ async function waitForUpdaterPopup(): Promise<UpdaterPopupEvalValue> {
await delay(1000);
}
throw new Error(`packaged mac updater popup did not appear: ${formatUnknown(lastResult)}`);
throw new Error(`${label}: updater popup timed out: ${formatUnknown(lastResult)}`);
}
async function waitForUpdaterInstallerOpened(): Promise<MacInspectResult> {