mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Gate every release cut on a render-level smoke suite
v6.2.0-rc.5 shipped with an empty Proxmox workloads table and a crashing Thresholds page (#1663) while the assertions that catch exactly that were failing in CI: spec 64 red in the non-gating probation tier two hours before the tag, and the gating Core E2E verdict red on the release commit itself — which the release pipeline never consults. integration_tests is also skipped entirely for prereleases, so the builds users test shipped with no integration coverage at all. Close the hole with a release_smoke job that runs for every cut, prereleases included, and blocks create_release and the release verdict: four interaction-free render assertions (Proxmox nodes+workloads, Docker hosts+containers, Kubernetes clusters+pods, Alert thresholds) against the mock-mode image built from the verified frontend bundle. The mock fixture graph now always contains one freshly provisioned zero-used guest filesystem, so the omitted-zero-numerics wire shape that crashed rc.5 stays exercised on every mock-backed surface. Verified locally: the suite passes on main and fails on the rc.5 frontend for exactly the two shipped regressions.
This commit is contained in:
parent
630d151460
commit
9ab38b940e
3 changed files with 223 additions and 2 deletions
102
.github/workflows/create-release.yml
vendored
102
.github/workflows/create-release.yml
vendored
|
|
@ -671,6 +671,99 @@ jobs:
|
|||
run: docker compose -f docker-compose.test.yml down -v || true
|
||||
|
||||
# Create release after all checks pass
|
||||
# Release smoke: render-level assertions on the primary surfaces (Proxmox,
|
||||
# Docker, Kubernetes, Alert thresholds), run for EVERY cut including
|
||||
# prereleases. integration_tests stays stable-only for depth; this job
|
||||
# exists because v6.2.0-rc.5 shipped with its primary surfaces broken while
|
||||
# the only coverage lived in non-gating CI tiers (#1663). A prerelease is
|
||||
# the build users test — it must never skip the "do the pages render data"
|
||||
# bar.
|
||||
release_smoke:
|
||||
needs:
|
||||
- prepare
|
||||
- frontend_checks
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
|
||||
- name: Download verified frontend bundle
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: frontend-modern/dist
|
||||
name: release-frontend-${{ github.sha }}
|
||||
|
||||
- name: Copy frontend to embed location
|
||||
run: |
|
||||
rm -rf internal/api/frontend-modern
|
||||
mkdir -p internal/api/frontend-modern
|
||||
cp -r frontend-modern/dist internal/api/frontend-modern/
|
||||
|
||||
- name: Build Docker image for the smoke environment
|
||||
# GO_BUILD_TAGS="" drops the release build tag so mock fixtures are
|
||||
# available; the release-tagged binary itself is covered by
|
||||
# backend_tests and build_release_candidate.
|
||||
run: |
|
||||
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
|
||||
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
||||
env:
|
||||
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||
|
||||
- name: Install Playwright
|
||||
working-directory: tests/integration
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run release smoke
|
||||
working-directory: tests/integration
|
||||
env:
|
||||
MOCK_CHECKSUM_ERROR: "false"
|
||||
MOCK_NETWORK_ERROR: "false"
|
||||
MOCK_RATE_LIMIT: "false"
|
||||
MOCK_STALE_RELEASE: "false"
|
||||
run: |
|
||||
docker compose -f docker-compose.test.yml up -d
|
||||
|
||||
echo "Waiting for services to be healthy..."
|
||||
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q "healthy"; do sleep 2; done'
|
||||
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q "healthy"; do sleep 2; done'
|
||||
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -f -s http://localhost:7655/api/health > /dev/null 2>&1; then
|
||||
echo "Pulse server is reachable"
|
||||
break
|
||||
elif [ $i -eq 5 ]; then
|
||||
docker logs pulse-test-server || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
npx playwright test tests/95-release-smoke.spec.ts \
|
||||
--project=chromium \
|
||||
--reporter=list
|
||||
|
||||
docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
- name: Collect smoke diagnostics
|
||||
if: failure()
|
||||
working-directory: tests/integration
|
||||
run: |
|
||||
docker logs pulse-test-server || true
|
||||
docker compose -f docker-compose.test.yml down -v || true
|
||||
|
||||
create_release:
|
||||
needs:
|
||||
- prepare
|
||||
|
|
@ -680,8 +773,10 @@ jobs:
|
|||
- docker_build
|
||||
- helm_smoke
|
||||
- integration_tests
|
||||
# Run if integration_tests passed OR was skipped (prereleases)
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
|
||||
- release_smoke
|
||||
# Run if integration_tests passed OR was skipped (prereleases). The
|
||||
# release smoke has no skipped escape: it runs for prereleases too.
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
|
|
@ -1367,6 +1462,7 @@ jobs:
|
|||
name: Definitive Release Verdict
|
||||
needs:
|
||||
- prepare
|
||||
- release_smoke
|
||||
- create_release
|
||||
- publish_docker
|
||||
- validate_release_assets
|
||||
|
|
@ -1384,6 +1480,7 @@ jobs:
|
|||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
||||
CREATE_RESULT: ${{ needs.create_release.result }}
|
||||
SMOKE_RESULT: ${{ needs.release_smoke.result }}
|
||||
DOCKER_RESULT: ${{ needs.publish_docker.result }}
|
||||
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
||||
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
||||
|
|
@ -1403,6 +1500,7 @@ jobs:
|
|||
fi
|
||||
}
|
||||
|
||||
require_result "release smoke" "$SMOKE_RESULT" success
|
||||
require_result "release assembly" "$CREATE_RESULT" success
|
||||
require_result "release asset validation" "$VALIDATE_RESULT" success
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,40 @@ func generateVirtualDisks() ([]models.Disk, models.Disk) {
|
|||
return disks, aggregated
|
||||
}
|
||||
|
||||
// ensureFreshFilesystemFixture guarantees the fixture graph always contains
|
||||
// at least one running guest with a zero-used filesystem. Freshly
|
||||
// provisioned mounts serialize with their zero-valued numerics omitted, a
|
||||
// wire shape consumers must tolerate (#1663), so every mock-backed surface
|
||||
// keeps exercising it.
|
||||
func ensureFreshFilesystemFixture(data *models.StateSnapshot) {
|
||||
const freshBytes = int64(64) * 1024 * 1024 * 1024
|
||||
fresh := models.Disk{
|
||||
Total: freshBytes,
|
||||
Used: 0,
|
||||
Free: freshBytes,
|
||||
Usage: 0,
|
||||
Mountpoint: "/srv/fresh",
|
||||
Type: "ext4",
|
||||
Device: "/dev/vdz",
|
||||
}
|
||||
for i := range data.VMs {
|
||||
vm := &data.VMs[i]
|
||||
if vm.Status != "running" || len(vm.Disks) == 0 {
|
||||
continue
|
||||
}
|
||||
vm.Disks = append(vm.Disks, fresh)
|
||||
return
|
||||
}
|
||||
for i := range data.Containers {
|
||||
ct := &data.Containers[i]
|
||||
if ct.Status != "running" || len(ct.Disks) == 0 {
|
||||
continue
|
||||
}
|
||||
ct.Disks = append(ct.Disks, fresh)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// buildFixtureState synthesizes the snapshot-backed portion of the canonical
|
||||
// fixture graph for demo and test environments.
|
||||
func buildFixtureState(config MockConfig) models.StateSnapshot {
|
||||
|
|
@ -567,6 +601,8 @@ func buildFixtureState(config MockConfig) models.StateSnapshot {
|
|||
|
||||
data.ReplicationJobs = generateReplicationJobs(data.Nodes, data.VMs)
|
||||
|
||||
ensureFreshFilesystemFixture(&data)
|
||||
|
||||
// Calculate stats
|
||||
data.Stats.StartTime = time.Now()
|
||||
data.Stats.Uptime = 0
|
||||
|
|
|
|||
87
tests/integration/tests/95-release-smoke.spec.ts
Normal file
87
tests/integration/tests/95-release-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ensureAuthenticated } from "./helpers";
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
|
||||
|
||||
// Release smoke: the minimum bar every cut ships against, prereleases
|
||||
// included. Each check asserts only that a primary surface renders real
|
||||
// inventory from the mock dataset — no interaction sequences, no timing
|
||||
// races — so a failure here always means the surface is broken, never that
|
||||
// the test is flaky. v6.2.0-rc.5 shipped with an empty Proxmox workloads
|
||||
// table and a crashing Thresholds page (#1663) while exactly these
|
||||
// assertions were failing in non-gating tiers; this spec exists so that
|
||||
// class of regression blocks the release instead.
|
||||
//
|
||||
// Keep this file interaction-free and assertion-light. Anything richer
|
||||
// belongs in the numbered feature specs, not here.
|
||||
|
||||
async function expectNoErrorBoundary(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This page couldn't load"),
|
||||
"an error boundary fired on a release-gating surface",
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Release smoke", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("mobile-"),
|
||||
"Release smoke gates on the desktop shell",
|
||||
);
|
||||
await page.setViewportSize(DESKTOP_VIEWPORT);
|
||||
await ensureAuthenticated(page);
|
||||
});
|
||||
|
||||
test("Proxmox overview renders nodes and workloads", async ({ page }) => {
|
||||
await page.goto("/proxmox", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Nodes and workloads travel different data paths (websocket state vs
|
||||
// /api/resources); rc.5 broke exactly one of them, so assert both.
|
||||
const nodeRows = page.locator("[data-proxmox-host-row]");
|
||||
await expect(nodeRows.first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
const workloadRows = page.locator("tr[data-guest-id]");
|
||||
await expect(workloadRows.first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await expect(page.getByText("No Proxmox workloads")).toHaveCount(0);
|
||||
await expectNoErrorBoundary(page);
|
||||
});
|
||||
|
||||
test("Docker page renders hosts and containers", async ({ page }) => {
|
||||
await page.goto("/docker", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.locator("tr[data-docker-host-row]").first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
page.locator("tr[data-docker-container-row]").first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expectNoErrorBoundary(page);
|
||||
});
|
||||
|
||||
test("Kubernetes page renders clusters and pods", async ({ page }) => {
|
||||
await page.goto("/kubernetes", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.locator("tr[data-kubernetes-cluster-row]").first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
page.locator("tr[data-kubernetes-pod-row]").first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expectNoErrorBoundary(page);
|
||||
});
|
||||
|
||||
test("Alert thresholds page renders guest sections", async ({ page }) => {
|
||||
await page.goto("/alerts/thresholds", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The mock dataset always contains a freshly provisioned zero-used
|
||||
// guest filesystem (ensureFreshFilesystemFixture), so this render
|
||||
// covers the omitted-zero-numerics wire shape that crashed rc.5.
|
||||
await expect(
|
||||
page.getByText("Virtualization Hosts", { exact: false }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expectNoErrorBoundary(page);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue