mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Fix release updater smoke recovery (#2687)
This commit is contained in:
parent
b4e94b0534
commit
af66a929bc
3 changed files with 157 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue