From b95d84fba7314e22456411e9006cd8c3eec4f509 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 8 Apr 2026 04:16:13 +0800 Subject: [PATCH] perf(stage-tamagotchi): better fallback, matrix smoking test --- apps/stage-tamagotchi/package.json | 3 +- .../scripts/update-test/README.md | 26 +++- .../update-test/generate-manifest.test.ts | 18 +++ .../scripts/update-test/generate-manifest.ts | 2 +- .../scripts/update-test/run-matrix.sh | 136 ++++++++++++++++++ .../scripts/update-test/setup.sh | 4 +- .../services/electron/auto-updater.test.ts | 75 ++++++++++ .../main/services/electron/auto-updater.ts | 81 +++++++++-- 8 files changed, 328 insertions(+), 17 deletions(-) create mode 100755 apps/stage-tamagotchi/scripts/update-test/run-matrix.sh diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 5c5a1cfab..3035702ef 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -31,7 +31,8 @@ "regenerate-windows-latest": "tsx scripts/regenerate-windows-latest.ts", "artifacts-metadata": "tsx scripts/artifacts-metadata.ts", "update-test:generate": "tsx scripts/update-test/generate-manifest.ts", - "update-test:server": "tsx scripts/update-test/start-server.ts" + "update-test:server": "tsx scripts/update-test/start-server.ts", + "update-test:matrix": "bash scripts/update-test/run-matrix.sh" }, "dependencies": { "@date-fns/utc": "^2.1.1", diff --git a/apps/stage-tamagotchi/scripts/update-test/README.md b/apps/stage-tamagotchi/scripts/update-test/README.md index 27a3b54b0..d189a4f7a 100644 --- a/apps/stage-tamagotchi/scripts/update-test/README.md +++ b/apps/stage-tamagotchi/scripts/update-test/README.md @@ -2,11 +2,10 @@ This directory provides a local mocked update-server workflow for Stage Tamagotchi. -It is intended to verify AIRI's refactored updater path: +It is intended to verify AIRI's updater path: - explicit `UPDATE_SERVER_URL` override mode -- no GitHub Releases API dependency -- no custom updater cache ownership +- lane switching (`stable`, `beta`, `alpha`, `nightly`) via `AIRI_UPDATE_CHANNEL` - developer-only updater diagnostics inspection ## Files @@ -38,6 +37,8 @@ Then, in another terminal: ```bash cd apps/stage-tamagotchi UPDATE_SERVER_URL=http://127.0.0.1:8787/stable pnpm run dev +# optional lane override: +# AIRI_UPDATE_CHANNEL=beta UPDATE_SERVER_URL=http://127.0.0.1:8787/beta pnpm run dev ``` ## Verification Flow @@ -63,12 +64,31 @@ You can also print the workflow commands with: bash apps/stage-tamagotchi/scripts/update-test/run-test.sh ``` +For automated matrix checks (lane x runtime feed mode + bundle-version test matrix), run: + +```bash +pnpm -F @proj-airi/stage-tamagotchi update-test:matrix +``` + +This script: + +- runs Vitest updater matrix tests (including bundled version: stable/beta/alpha) +- generates local fixtures for `stable`, `beta`, `alpha`, `nightly` +- runs packaged app checks for two runtime modes: + - `UPDATE_SERVER_URL` override mode + - no override (GitHub lane resolution mode) +- captures logs and summaries under `scripts/update-test/artifacts/` +- writes a green/red matrix report at `scripts/update-test/artifacts//summary.md` + Environment variables supported by the wrapper: - `PORT` - `CHANNEL` - `TARGET` - `VERSION` +- `AIRI_UPDATE_CHANNEL` (at app launch time; independent from `CHANNEL`) +- `RUN_SECONDS` (matrix app runtime per case; default `18`) +- `LOG_DIR` (matrix artifact directory override) Common targets: diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts index cdf80ce8e..e1bf2855b 100644 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts @@ -60,4 +60,22 @@ describe('generateManifestFixtures', () => { expect(manifest.files[0]?.size).toBeGreaterThan(0) await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe('mock-installer-binary') }) + + it.each(['stable', 'beta', 'alpha', 'nightly'] as const)('supports channel fixtures for %s', async (channel) => { + const root = await mkdtemp(join(tmpdir(), 'airi-update-test-')) + roots.push(root) + + const result = await generateManifestFixtures({ + rootDir: root, + channel, + target: 'aarch64-apple-darwin', + version: '9.9.9-test.2', + releaseNotes: 'Mock update lane fixture', + artifactContent: `mock-installer-${channel}`, + }) + + expect(result.channelDir).toBe(join(root, channel)) + expect(result.latestFilename).toBe('latest-arm64-mac.yml') + await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe(`mock-installer-${channel}`) + }) }) diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts index 613436f35..891425d82 100644 --- a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts @@ -10,7 +10,7 @@ import * as yaml from 'yaml' import { getFilenames } from '../utils' -export type UpdateTestChannel = 'stable' | 'nightly' | 'canary' +export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary' export interface GenerateManifestFixturesOptions { rootDir: string diff --git a/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh b/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh new file mode 100755 index 000000000..0f054e14e --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/run-matrix.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +APP_BIN="${APP_DIR}/dist/mac-arm64/airi.app/Contents/MacOS/airi" + +PORT="${PORT:-8787}" +RUN_SECONDS="${RUN_SECONDS:-18}" +LOG_DIR="${LOG_DIR:-${SCRIPT_DIR}/artifacts/matrix-$(date +%Y%m%d-%H%M%S)}" +SUMMARY_TSV="${LOG_DIR}/summary.tsv" +SUMMARY_MD="${LOG_DIR}/summary.md" + +LANES=(stable beta alpha nightly) +RUNTIME_MODES=(override github) + +mkdir -p "${LOG_DIR}" +printf "mode\tlane\tstatus\treason\tsummary\n" > "${SUMMARY_TSV}" + +if [[ ! -x "${APP_BIN}" ]]; then + echo "Packaged app not found: ${APP_BIN}" + echo "Build first: rm -rf apps/stage-tamagotchi/dist && pnpm -F @proj-airi/stage-tamagotchi build:mac" + exit 1 +fi + +cd "${REPO_ROOT}" + +echo "==> Running updater matrix unit tests (includes bundle-version matrix)" +pnpm exec vitest run \ + apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts \ + apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts + +echo "==> Preparing fixture directories" +bash "${SCRIPT_DIR}/setup.sh" + +echo "==> Generating local update fixtures for lanes: ${LANES[*]}" +for lane in "${LANES[@]}"; do + pnpm -F @proj-airi/stage-tamagotchi update-test:generate \ + --root scripts/update-test/fixtures/server \ + --channel "${lane}" \ + --target aarch64-apple-darwin \ + --version "9.9.9-${lane}.1" \ + --release-notes "mock ${lane}" +done + +echo "==> Starting local update-test server on port ${PORT}" +pnpm -F @proj-airi/stage-tamagotchi update-test:server \ + --port "${PORT}" \ + --root scripts/update-test/fixtures/server \ + > "${LOG_DIR}/server.log" 2>&1 & +SERVER_PID=$! + +cleanup() { + kill "${SERVER_PID}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +for lane in "${LANES[@]}"; do + for _ in {1..40}; do + if curl -fsS "http://127.0.0.1:${PORT}/${lane}/latest-arm64-mac.yml" >/dev/null 2>&1; then + break + fi + sleep 0.2 + done +done + +run_case() { + local mode="$1" + local lane="$2" + local log_file="${LOG_DIR}/${mode}-${lane}.log" + local app_pid="" + + echo "==> Running mode=${mode}, lane=${lane}" + if [[ "${mode}" == "override" ]]; then + UPDATE_SERVER_URL="http://127.0.0.1:${PORT}/${lane}" AIRI_UPDATE_CHANNEL="${lane}" "${APP_BIN}" > "${log_file}" 2>&1 & + app_pid=$! + else + AIRI_UPDATE_CHANNEL="${lane}" "${APP_BIN}" > "${log_file}" 2>&1 & + app_pid=$! + fi + + sleep "${RUN_SECONDS}" + kill "${app_pid}" >/dev/null 2>&1 || true + for _ in {1..20}; do + if ! kill -0 "${app_pid}" >/dev/null 2>&1; then + break + fi + sleep 0.2 + done + if kill -0 "${app_pid}" >/dev/null 2>&1; then + kill -KILL "${app_pid}" >/dev/null 2>&1 || true + fi + wait "${app_pid}" 2>/dev/null || true + + local matched + local status="GREEN" + local reason="ok" + matched="$(rg -n "auto-updater|applied generic feed override|checkForUpdates\\(\\) failed|No published versions on GitHub|update-available|update-not-available" "${log_file}" || true)" + if [[ -z "${matched}" ]]; then + status="RED" + reason="no-updater-log" + echo " [warn] no updater logs matched in ${log_file}" + else + echo "${matched}" > "${LOG_DIR}/${mode}-${lane}.summary.log" + if rg -q "checkForUpdates\\(\\) failed|No published versions on GitHub|No GitHub release found|Cannot find channel|HttpError: 404|\\[error\\]" "${LOG_DIR}/${mode}-${lane}.summary.log"; then + status="RED" + reason="updater-error" + fi + echo " [ok] summary: ${LOG_DIR}/${mode}-${lane}.summary.log" + fi + printf "%s\t%s\t%s\t%s\t%s\n" "${mode}" "${lane}" "${status}" "${reason}" "${LOG_DIR}/${mode}-${lane}.summary.log" >> "${SUMMARY_TSV}" +} + +for mode in "${RUNTIME_MODES[@]}"; do + for lane in "${LANES[@]}"; do + run_case "${mode}" "${lane}" + done +done + +{ + echo "| mode | lane | status | reason | summary |" + echo "|---|---|---|---|---|" + tail -n +2 "${SUMMARY_TSV}" | while IFS=$'\t' read -r mode lane status reason summary; do + echo "| ${mode} | ${lane} | ${status} | ${reason} | ${summary} |" + done +} > "${SUMMARY_MD}" + +echo +echo "Matrix run complete." +echo "Artifacts:" +echo "- ${LOG_DIR}" +echo "- ${LOG_DIR}/server.log" +echo "- ${LOG_DIR}/*.summary.log" +echo "- ${SUMMARY_MD}" diff --git a/apps/stage-tamagotchi/scripts/update-test/setup.sh b/apps/stage-tamagotchi/scripts/update-test/setup.sh index 3397dd20a..24703880c 100755 --- a/apps/stage-tamagotchi/scripts/update-test/setup.sh +++ b/apps/stage-tamagotchi/scripts/update-test/setup.sh @@ -5,9 +5,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" mkdir -p "${SCRIPT_DIR}/fixtures/server/stable" +mkdir -p "${SCRIPT_DIR}/fixtures/server/beta" +mkdir -p "${SCRIPT_DIR}/fixtures/server/alpha" mkdir -p "${SCRIPT_DIR}/fixtures/server/nightly" mkdir -p "${SCRIPT_DIR}/fixtures/server/canary" -chmod +x "${SCRIPT_DIR}/setup.sh" "${SCRIPT_DIR}/run-test.sh" +chmod +x "${SCRIPT_DIR}/setup.sh" "${SCRIPT_DIR}/run-test.sh" "${SCRIPT_DIR}/run-matrix.sh" echo "Prepared update-test fixtures in ${SCRIPT_DIR}/fixtures/server" diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts index 0e5f5209e..16613858f 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts @@ -79,9 +79,28 @@ vi.mock('~build/git', () => ({ })) describe('setupAutoUpdater', () => { + const laneReleaseTagMap = { + stable: 'v0.9.9', + beta: 'v0.9.10-beta.3', + alpha: 'v0.9.11-alpha.4', + nightly: 'v0.9.12-nightly.7', + } as const + const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const + const laneMatrix = ['stable', 'beta', 'alpha', 'nightly'] as const + const defaultReleases = [ { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true }, ] + const matrixReleases = [ + { tag_name: 'v0.9.7', draft: false, prerelease: false }, + { tag_name: 'v0.9.9', draft: false, prerelease: false }, + { tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true }, + { tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true }, + { tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true }, + { tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true }, + { tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true }, + { tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true }, + ] function mockGitHubReleasesFetch(releases = defaultReleases) { const fetchSpy = vi.fn().mockResolvedValue({ @@ -173,6 +192,62 @@ describe('setupAutoUpdater', () => { }) }) + it.each(laneMatrix)('supports AIRI_UPDATE_CHANNEL override for lane=%s', async (lane) => { + appMock.getVersion.mockReturnValue('0.9.0-alpha.2') + process.env.AIRI_UPDATE_CHANNEL = lane + mockGitHubReleasesFetch(matrixReleases) + + const { setupAutoUpdater } = await import('./auto-updater') + const service = setupAutoUpdater() + await service.checkForUpdates() + + expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: `https://github.com/moeru-ai/airi/releases/download/${laneReleaseTagMap[lane]}`, + }) + }) + + it.each(bundleVersions)('uses bundled version lane when no AIRI_UPDATE_CHANNEL (bundle=%s)', async (bundleVersion) => { + appMock.getVersion.mockReturnValue(bundleVersion) + mockGitHubReleasesFetch(matrixReleases) + + const { setupAutoUpdater } = await import('./auto-updater') + const service = setupAutoUpdater() + await service.checkForUpdates() + + const expectedLane = bundleVersion.includes('-beta') + ? 'beta' + : bundleVersion.includes('-alpha') + ? 'alpha' + : 'stable' + + expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: `https://github.com/moeru-ai/airi/releases/download/${laneReleaseTagMap[expectedLane]}`, + }) + }) + + it.each(bundleVersions.flatMap(bundleVersion => laneMatrix.map(lane => ({ bundleVersion, lane }))))( + 'matrix lane/feed/bundle works with UPDATE_SERVER_URL override (%o)', + async ({ bundleVersion, lane }) => { + appMock.getVersion.mockReturnValue(bundleVersion) + isDevState.value = true + process.env.AIRI_UPDATE_CHANNEL = lane + process.env.UPDATE_SERVER_URL = `http://127.0.0.1:8787/${lane}` + + const fetchSpy = mockGitHubReleasesFetch(matrixReleases) + const { setupAutoUpdater } = await import('./auto-updater') + const service = setupAutoUpdater() + await service.checkForUpdates() + + expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: `http://127.0.0.1:8787/${lane}`, + }) + expect(fetchSpy).not.toHaveBeenCalled() + }, + ) + it('reports only authoritative diagnostics fields', async () => { const { setupAutoUpdater } = await import('./auto-updater') const service = setupAutoUpdater() diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts index 66fb2f42b..565b4a4ef 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -30,6 +30,7 @@ function getReleaseChannelName() { } const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/moeru-ai/airi/releases?per_page=100' +const GITHUB_RELEASES_ATOM_URL = 'https://github.com/moeru-ai/airi/releases.atom' const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/moeru-ai/airi/releases/download' const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL' @@ -105,6 +106,49 @@ function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLan return candidates[0]?.tag } +/** + * Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies. + * + * The current feed contains entries like: + * `` + * and + * `tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36` + * + * We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links. + */ +function extractReleaseTagsFromAtom(atom: string) { + const tags: string[] = [] + const marker = '/moeru-ai/airi/releases/tag/' + let offset = 0 + + while (offset < atom.length) { + const markerIndex = atom.indexOf(marker, offset) + if (markerIndex === -1) + break + + const start = markerIndex + marker.length + let end = start + while (end < atom.length) { + const char = atom[end] + if (char === '"' || char === '<' || char === '?' || char === '&') + break + end += 1 + } + + // Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`. + const rawTag = atom.slice(start, end).trim() + // Atom encodes URLs, so decode in case future tags contain escaped characters. + const decodedTag = decodeURIComponent(rawTag) + // Feed entries can repeat across updates; keep a unique ordered tag list. + if (decodedTag && !tags.includes(decodedTag)) + tags.push(decodedTag) + + offset = end + 1 + } + + return tags +} + export interface AppUpdaterLike { on: (event: string, listener: (...args: any[]) => void) => any checkForUpdates: () => Promise @@ -232,20 +276,35 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater } async function resolveGitHubReleaseTagForLane(lane: UpdateLane) { - const response = await fetch(GITHUB_RELEASES_API_URL, { - headers: { - accept: 'application/vnd.github+json', - }, - }) + try { + const response = await fetch(GITHUB_RELEASES_API_URL, { + headers: { + accept: 'application/vnd.github+json', + }, + }) - if (!response.ok) - throw new Error(`Failed to fetch GitHub releases (${response.status} ${response.statusText})`) + if (!response.ok) + throw new Error(`Failed to fetch GitHub releases (${response.status} ${response.statusText})`) - const payload = await response.json() - if (!Array.isArray(payload)) - throw new Error('Unexpected GitHub releases payload shape') + const payload = await response.json() + if (!Array.isArray(payload)) + throw new Error('Unexpected GitHub releases payload shape') - const tag = selectLatestTagForLane(payload as GitHubReleaseRecord[], lane) + const tag = selectLatestTagForLane(payload as GitHubReleaseRecord[], lane) + if (tag) + return tag + } + catch (error) { + log.withError(error).warn('GitHub releases API lookup failed, trying releases.atom fallback') + } + + const atomResponse = await fetch(GITHUB_RELEASES_ATOM_URL) + if (!atomResponse.ok) + throw new Error(`Failed to fetch GitHub releases atom (${atomResponse.status} ${atomResponse.statusText})`) + + const atom = await atomResponse.text() + const releasesFromAtom = extractReleaseTagsFromAtom(atom).map(tag => ({ tag_name: tag })) + const tag = selectLatestTagForLane(releasesFromAtom, lane) if (!tag) throw new Error(`No GitHub release found for update lane "${lane}"`)