perf(stage-tamagotchi): better fallback, matrix smoking test
This commit is contained in:
@@ -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
@@ -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}"`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user