perf(stage-tamagotchi): better fallback, matrix smoking test

This commit is contained in:
Neko Ayaka
2026-04-08 04:16:13 +08:00
parent c2dba69a8b
commit b95d84fba7
8 changed files with 328 additions and 17 deletions
+2 -1
View File
@@ -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",
@@ -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/<run-id>/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:
@@ -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}`)
})
})
@@ -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
+136
View File
@@ -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}"
@@ -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"
@@ -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()
@@ -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:
* `<entry><link rel="alternate" type="text/html" href="https://github.com/moeru-ai/airi/releases/tag/v0.9.0-beta.6"/></entry>`
* and
* `<entry><id>tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36</id></entry>`
*
* 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<any>
@@ -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}"`)