Merge remote-tracking branch 'origin/main' into feat/voice-aurora-stage
This commit is contained in:
commit
3b5d0d2b95
14
.github/workflows/evo-deploy.yml
vendored
14
.github/workflows/evo-deploy.yml
vendored
@ -1,9 +1,11 @@
|
|||||||
name: evo-deploy
|
name: evo-deploy
|
||||||
|
|
||||||
# Deploy evOo-local on the Evo host when main advances, or on manual dispatch.
|
# Deploy evOo-local on the Evo host once Product CI has passed on main, or on manual dispatch.
|
||||||
# Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow).
|
# Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow).
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [ci]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@ -29,7 +31,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
if: vars.EVO_DEPLOY_ENABLED == 'true'
|
if: vars.EVO_DEPLOY_ENABLED == 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
|
||||||
runs-on: [self-hosted, evoo-ci, Linux]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@ -37,12 +39,12 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Deploy evOo-local on Evo
|
- name: Deploy evOo-local on Evo
|
||||||
env:
|
env:
|
||||||
EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }}
|
EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }}
|
||||||
EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}
|
||||||
EVO_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
EVO_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||||
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
|
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
|
||||||
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
|
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
|
||||||
@ -72,7 +74,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "### Evo deploy"
|
echo "### Evo deploy"
|
||||||
echo
|
echo
|
||||||
echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}\`"
|
echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}\`"
|
||||||
echo "- Dir: \`$EVO_DEPLOY_DIR\`"
|
echo "- Dir: \`$EVO_DEPLOY_DIR\`"
|
||||||
if [[ -f "$EVO_STATE_FILE" ]]; then
|
if [[ -f "$EVO_STATE_FILE" ]]; then
|
||||||
echo "- State:"
|
echo "- State:"
|
||||||
|
|||||||
18
.github/workflows/playwright.yml
vendored
18
.github/workflows/playwright.yml
vendored
@ -111,6 +111,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
||||||
run: test -n "$BOX_API_KEY"
|
run: test -n "$BOX_API_KEY"
|
||||||
|
- name: Pick E2E ports for this runner
|
||||||
|
# Several runners share one host. Each runner gets its own port set so the
|
||||||
|
# port-freeing steps and the harness never touch another job's servers. No runner
|
||||||
|
# uses the harness defaults: jobs from branches that still free the fixed ports
|
||||||
|
# cannot kill ours, and we cannot kill theirs.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
suffix="${RUNNER_NAME##*-}"
|
||||||
|
offset=100
|
||||||
|
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
|
||||||
|
offset=$((suffix * 100))
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "API_PORT=$((3110 + offset))"
|
||||||
|
echo "WEB_PORT=$((5180 + offset))"
|
||||||
|
echo "WWW_PORT=$((4321 + offset))"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
echo "E2E ports for ${RUNNER_NAME}: api $((3110 + offset)), web $((5180 + offset)), www $((4321 + offset))"
|
||||||
- name: Free stale E2E ports
|
- name: Free stale E2E ports
|
||||||
run: bash scripts/free-e2e-ports.sh
|
run: bash scripts/free-e2e-ports.sh
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
|
|||||||
38
.github/workflows/publish-server-image.yml
vendored
38
.github/workflows/publish-server-image.yml
vendored
@ -71,10 +71,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
- name: Resolve the lowercase image namespace
|
||||||
|
id: ns
|
||||||
|
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
|
||||||
|
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||||
- id: meta
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
tags: type=sha,prefix=sha-
|
tags: type=sha,prefix=sha-
|
||||||
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
@ -93,8 +97,8 @@ jobs:
|
|||||||
name: Build ${{ matrix.name }} ${{ matrix.arch }}
|
name: Build ${{ matrix.name }} ${{ matrix.arch }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
# Each architecture builds natively on its own runner and pushes by digest only.
|
# Each architecture builds natively on its own runner and pushes by digest only.
|
||||||
# amd64 on evo Linux runners; arm64 on Apple Silicon macOS runners (linux/arm64 via buildx).
|
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
|
||||||
# No QEMU: emulated arm64 took hours for the desktop-sized computer image.
|
# and QEMU emulation took hours for the desktop-sized computer image.
|
||||||
# See docs/self-host.md (Published images and tags).
|
# See docs/self-host.md (Published images and tags).
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
@ -105,7 +109,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
name: [app, updater, computer]
|
name: [app, updater, computer]
|
||||||
arch: [amd64, arm64]
|
arch: [amd64]
|
||||||
include:
|
include:
|
||||||
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
|
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
|
||||||
# The supervisor is not a separate published image: it runs from `app` on the internal network.
|
# The supervisor is not a separate published image: it runs from `app` on the internal network.
|
||||||
@ -120,8 +124,6 @@ jobs:
|
|||||||
context: infra/sandboxes/computer
|
context: infra/sandboxes/computer
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
runner: [self-hosted, evoo-ci, Linux]
|
runner: [self-hosted, evoo-ci, Linux]
|
||||||
- arch: arm64
|
|
||||||
runner: [self-hosted, evoo-ci, macOS]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||||
with:
|
with:
|
||||||
@ -133,11 +135,15 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Resolve the lowercase image namespace
|
||||||
|
id: ns
|
||||||
|
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
|
||||||
|
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||||
- id: meta
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# Labels only; the tagged manifest is assembled in the publish job.
|
# Labels only; the tagged manifest is assembled in the publish job.
|
||||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
tags: type=sha,prefix=sha-
|
tags: type=sha,prefix=sha-
|
||||||
- id: build
|
- id: build
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
@ -147,7 +153,7 @@ jobs:
|
|||||||
platforms: linux/${{ matrix.arch }}
|
platforms: linux/${{ matrix.arch }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
# A fork can publish only to its own namespace; no credential can redirect this value.
|
# A fork can publish only to its own namespace; no credential can redirect this value.
|
||||||
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
# GET /health can report the exact source commit without a deployment-supplied override.
|
# GET /health can report the exact source commit without a deployment-supplied override.
|
||||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||||
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
|
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
|
||||||
@ -199,11 +205,15 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Resolve the lowercase image namespace
|
||||||
|
id: ns
|
||||||
|
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
|
||||||
|
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||||
- id: meta
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# A fork can publish only to its own namespace; no credential can redirect this value.
|
# A fork can publish only to its own namespace; no credential can redirect this value.
|
||||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern=v{{version}}
|
type=semver,pattern=v{{version}}
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
@ -211,11 +221,11 @@ jobs:
|
|||||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
|
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
|
||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
|
||||||
- name: Assemble and verify the multi-arch manifest
|
- name: Assemble and verify the manifest
|
||||||
id: manifest
|
id: manifest
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
env:
|
env:
|
||||||
IMAGE: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -227,7 +237,7 @@ jobs:
|
|||||||
for digest in *; do
|
for digest in *; do
|
||||||
sources+=("${IMAGE}@sha256:${digest}")
|
sources+=("${IMAGE}@sha256:${digest}")
|
||||||
done
|
done
|
||||||
if [[ "${#sources[@]}" -ne 2 ]]; then
|
if [[ "${#sources[@]}" -ne 1 ]]; then
|
||||||
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
|
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -236,13 +246,13 @@ jobs:
|
|||||||
first_tag="$(head -n 1 <<< "$TAGS")"
|
first_tag="$(head -n 1 <<< "$TAGS")"
|
||||||
platforms="$(docker buildx imagetools inspect "$first_tag" \
|
platforms="$(docker buildx imagetools inspect "$first_tag" \
|
||||||
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')"
|
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')"
|
||||||
for want in linux/amd64 linux/arm64; do
|
for want in linux/amd64; do
|
||||||
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
|
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
|
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
|
||||||
- name: Attest the published image
|
- name: Attest the published image
|
||||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|||||||
38
apps/web/e2e/module-recovery.spec.ts
Normal file
38
apps/web/e2e/module-recovery.spec.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/** Abort the first request for a module once, then let every later request through. */
|
||||||
|
async function abortFirstRequest(page: Page, pattern: string) {
|
||||||
|
const counter = { aborted: 0 };
|
||||||
|
await page.route(pattern, async (route) => {
|
||||||
|
if (counter.aborted === 0) {
|
||||||
|
counter.aborted += 1;
|
||||||
|
await route.abort("failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the app reloads itself once when a module request aborts while booting", async ({ page }) => {
|
||||||
|
const counter = await abortFirstRequest(page, "**/src/App.tsx");
|
||||||
|
|
||||||
|
await page.goto("/sign-in", { waitUntil: "commit" });
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
|
||||||
|
expect(counter.aborted).toBe(1);
|
||||||
|
expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lazy route recovers when its module request aborts", async ({ page }) => {
|
||||||
|
const counter = await abortFirstRequest(page, "**/src/pages/Auth.tsx");
|
||||||
|
|
||||||
|
await page.goto("/sign-in");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
|
||||||
|
expect(counter.aborted).toBe(1);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
@ -29,6 +29,26 @@
|
|||||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Reload once when the browser aborts part of the module graph while the app
|
||||||
|
// boots (a network interface changed mid-request). main.tsx clears the flag
|
||||||
|
// after a successful boot; a second failure in a row is left to the browser.
|
||||||
|
addEventListener(
|
||||||
|
"error",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLScriptElement) || target.type !== "module") return;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("rakazo:boot-reload")) return;
|
||||||
|
sessionStorage.setItem("rakazo:boot-reload", "1");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
|
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
|
||||||
import { Button, Skeleton } from "@rakazo/ui-web";
|
import { Button, Skeleton } from "@rakazo/ui-web";
|
||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
|
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
|
||||||
import { LoadingState } from "./components/ai/primitives";
|
import { LoadingState } from "./components/ai/primitives";
|
||||||
import { authClient } from "./lib/auth";
|
import { authClient } from "./lib/auth";
|
||||||
|
import { lazyWithRecovery } from "./lib/lazy-recovery";
|
||||||
import { markAfterPaint, markOnce } from "./lib/performance";
|
import { markAfterPaint, markOnce } from "./lib/performance";
|
||||||
import {
|
import {
|
||||||
holdUnreachableGate,
|
holdUnreachableGate,
|
||||||
@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings";
|
|||||||
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
||||||
import { ShellPage } from "./pages/Shell";
|
import { ShellPage } from "./pages/Shell";
|
||||||
|
|
||||||
const AuthPage = lazy(() =>
|
const AuthPage = lazyWithRecovery("pages/Auth", () =>
|
||||||
import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
|
import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
|
||||||
);
|
);
|
||||||
const PasswordResetPage = lazy(() =>
|
const PasswordResetPage = lazyWithRecovery("pages/Auth", () =>
|
||||||
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
|
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
|
||||||
);
|
);
|
||||||
const OnboardingPage = lazy(() =>
|
const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
|
||||||
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
|
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
|
||||||
);
|
);
|
||||||
const WelcomePage = lazy(() =>
|
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
|
||||||
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
78
apps/web/src/lib/lazy-recovery.test.ts
Normal file
78
apps/web/src/lib/lazy-recovery.test.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadWithRecovery } from "./lazy-recovery.js";
|
||||||
|
|
||||||
|
function fakeStorage(initial: Record<string, string> = {}) {
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => void values.set(key, value),
|
||||||
|
removeItem: (key: string) => void values.delete(key),
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFailure = () => new TypeError("Failed to fetch dynamically imported module");
|
||||||
|
const flag = "rakazo:module-reload:pages/Onboarding";
|
||||||
|
|
||||||
|
describe("loadWithRecovery", () => {
|
||||||
|
it("returns the module and clears the reload flag when the import succeeds", async () => {
|
||||||
|
const storage = fakeStorage({ [flag]: "1" });
|
||||||
|
const reload = vi.fn();
|
||||||
|
const loaded = await loadWithRecovery("pages/Onboarding", async () => "module", {
|
||||||
|
storage,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
expect(loaded).toBe("module");
|
||||||
|
expect(storage.values.has(flag)).toBe(false);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the import in place before touching the page", async () => {
|
||||||
|
const storage = fakeStorage();
|
||||||
|
const reload = vi.fn();
|
||||||
|
const load = vi
|
||||||
|
.fn<() => Promise<string>>()
|
||||||
|
.mockRejectedValueOnce(fetchFailure())
|
||||||
|
.mockResolvedValueOnce("module");
|
||||||
|
const loaded = await loadWithRecovery("pages/Onboarding", load, {
|
||||||
|
storage,
|
||||||
|
reload,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
expect(loaded).toBe("module");
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(storage.values.has(flag)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the page once when the retry fails as well", async () => {
|
||||||
|
const storage = fakeStorage();
|
||||||
|
const reload = vi.fn();
|
||||||
|
const load = vi.fn<() => Promise<string>>().mockRejectedValue(fetchFailure());
|
||||||
|
const pending = loadWithRecovery("pages/Onboarding", load, {
|
||||||
|
storage,
|
||||||
|
reload,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
pending.then(() => "resolved"),
|
||||||
|
new Promise<string>((resolve) => setTimeout(() => resolve("still pending"), 20)),
|
||||||
|
]);
|
||||||
|
expect(outcome).toBe("still pending");
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage.values.get(flag)).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the failure instead of reloading again after a reload did not help", async () => {
|
||||||
|
const storage = fakeStorage({ [flag]: "1" });
|
||||||
|
const reload = vi.fn();
|
||||||
|
const failure = fetchFailure();
|
||||||
|
const load = vi.fn<() => Promise<string>>().mockRejectedValue(failure);
|
||||||
|
await expect(
|
||||||
|
loadWithRecovery("pages/Onboarding", load, { storage, reload, retryDelayMs: 0 }),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(storage.values.get(flag)).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/web/src/lib/lazy-recovery.ts
Normal file
58
apps/web/src/lib/lazy-recovery.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
export interface RecoveryOptions {
|
||||||
|
/** Pause before the in-place retry; the default covers a brief interface change. */
|
||||||
|
retryDelayMs?: number;
|
||||||
|
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||||
|
reload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "rakazo:module-reload:";
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a lazily imported module, recovering from a fetch the browser aborted
|
||||||
|
* (a network interface changed, the deploy behind the URL moved on). The first
|
||||||
|
* recovery is a plain retry; when that fails too, the page reloads once, because
|
||||||
|
* browsers keep a failed module in their module map and keep rejecting the same
|
||||||
|
* import without touching the network. A second failure after the reload is
|
||||||
|
* surfaced to the caller.
|
||||||
|
*/
|
||||||
|
export async function loadWithRecovery<T>(
|
||||||
|
key: string,
|
||||||
|
load: () => Promise<T>,
|
||||||
|
options: RecoveryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const storage = options.storage ?? window.sessionStorage;
|
||||||
|
const reload = options.reload ?? (() => window.location.reload());
|
||||||
|
const storageKey = `${STORAGE_PREFIX}${key}`;
|
||||||
|
try {
|
||||||
|
const loaded = await load();
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
return loaded;
|
||||||
|
} catch {
|
||||||
|
await delay(options.retryDelayMs ?? 300);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const loaded = await load();
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
return loaded;
|
||||||
|
} catch (error) {
|
||||||
|
if (storage.getItem(storageKey)) throw error;
|
||||||
|
storage.setItem(storageKey, "1");
|
||||||
|
reload();
|
||||||
|
return new Promise<never>(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */
|
||||||
|
export function lazyWithRecovery<T extends ComponentType<any>>(
|
||||||
|
key: string,
|
||||||
|
load: () => Promise<{ default: T }>,
|
||||||
|
) {
|
||||||
|
return lazy(() => loadWithRecovery(key, load));
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale";
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
markOnce("rk:renderer:module-evaluated");
|
markOnce("rk:renderer:module-evaluated");
|
||||||
|
try {
|
||||||
|
// Set by the inline boot script in index.html before it reloads once.
|
||||||
|
sessionStorage.removeItem("rakazo:boot-reload");
|
||||||
|
} catch {}
|
||||||
installPreloadRecovery();
|
installPreloadRecovery();
|
||||||
applyUiDirection(resolveUiLocale());
|
applyUiDirection(resolveUiLocale());
|
||||||
applyUiAppearance();
|
applyUiAppearance();
|
||||||
|
|||||||
@ -139,7 +139,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
lazy,
|
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
memo,
|
memo,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
@ -202,6 +201,7 @@ import {
|
|||||||
resolveWorkingGroupBots,
|
resolveWorkingGroupBots,
|
||||||
} from "../lib/group-stage";
|
} from "../lib/group-stage";
|
||||||
import { buildHeuteBrief } from "../lib/heute-brief";
|
import { buildHeuteBrief } from "../lib/heute-brief";
|
||||||
|
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
||||||
import { localTimezone } from "../lib/local-timezone";
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import { copyableMessageText } from "../lib/message-text";
|
import { copyableMessageText } from "../lib/message-text";
|
||||||
import { messageProviderLabel } from "../lib/messaging";
|
import { messageProviderLabel } from "../lib/messaging";
|
||||||
@ -283,27 +283,29 @@ import {
|
|||||||
} from "./shell/message-cards";
|
} from "./shell/message-cards";
|
||||||
import { WindowChrome } from "./WindowChrome";
|
import { WindowChrome } from "./WindowChrome";
|
||||||
|
|
||||||
const BotContextMenu = lazy(() =>
|
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
||||||
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
|
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
|
||||||
);
|
);
|
||||||
const MessagingSettingsOverlay = lazy(() =>
|
const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
|
||||||
import("./MessagingSettingsOverlay").then((module) => ({
|
import("./MessagingSettingsOverlay").then((module) => ({
|
||||||
default: module.MessagingSettingsOverlay,
|
default: module.MessagingSettingsOverlay,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const SettingsOverlay = lazy(() =>
|
const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
|
||||||
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
|
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
|
||||||
);
|
);
|
||||||
const PeerMessagesOverlay = lazy(() =>
|
const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
|
||||||
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
|
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
|
||||||
);
|
);
|
||||||
const PluginsOverlay = lazy(() =>
|
const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
|
||||||
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
|
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
|
||||||
);
|
);
|
||||||
const McpServersOverlay = lazy(() =>
|
const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
|
||||||
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
|
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
|
||||||
);
|
);
|
||||||
const CallView = lazy(() => import("./CallView").then((module) => ({ default: module.CallView })));
|
const CallView = lazyWithRecovery("CallView", () =>
|
||||||
|
import("./CallView").then((module) => ({ default: module.CallView })),
|
||||||
|
);
|
||||||
|
|
||||||
type Panel =
|
type Panel =
|
||||||
| "computer"
|
| "computer"
|
||||||
|
|||||||
@ -25,15 +25,16 @@ import {
|
|||||||
Toggle,
|
Toggle,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { lazy, Suspense, useEffect, useId, useRef, useState } from "react";
|
import { Suspense, useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { lazyWithRecovery } from "../../lib/lazy-recovery";
|
||||||
import { rpc } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { AvatarStudioPopover } from "./avatar-studio-popover";
|
import { AvatarStudioPopover } from "./avatar-studio-popover";
|
||||||
|
|
||||||
const ScratchpadSection = lazy(() =>
|
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
|
||||||
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
|
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const KnowledgeSection = lazy(() =>
|
const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
|
||||||
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
|
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -36,8 +36,9 @@ Compose stack requires `SANDBOX_SUPERVISOR_TOKEN` for every provider; leave it e
|
|||||||
|
|
||||||
Optional: set `OPENROUTER_API_KEY` or connect a model in the UI after signup.
|
Optional: set `OPENROUTER_API_KEY` or connect a model in the UI after signup.
|
||||||
|
|
||||||
The example defaults to `edge` (main builds). Every publish is multi-arch (`amd64` + `arm64`), so
|
The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now;
|
||||||
arm64 hosts need no special tag. Do not assume `latest` is present until a stable release exists.
|
arm64 publishing is paused until an arm64 runner with Docker buildx exists. Do not assume `latest`
|
||||||
|
is present until a stable release exists.
|
||||||
|
|
||||||
Open [http://127.0.0.1:5173](http://127.0.0.1:5173). The first registered user becomes the
|
Open [http://127.0.0.1:5173](http://127.0.0.1:5173). The first registered user becomes the
|
||||||
deployment owner. Put TLS in front of `:5173` for a public host and set the three public origins to
|
deployment owner. Put TLS in front of `:5173` for a public host and set the three public origins to
|
||||||
@ -483,8 +484,9 @@ your CI cannot publish into someone else's.
|
|||||||
| `sha-<full-commit>` | every push and manual run | source-addressed; used by the updater sidecar |
|
| `sha-<full-commit>` | every push and manual run | source-addressed; used by the updater sidecar |
|
||||||
| `edge` | pushes to main | yes, to the newest main build |
|
| `edge` | pushes to main | yes, to the newest main build |
|
||||||
|
|
||||||
Every publish, including `edge` from main merges, is multi-arch (`amd64` + `arm64`): each
|
Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds
|
||||||
architecture builds natively on its own runner and one manifest is assembled per image. Until a
|
natively on the Linux runner and one manifest is assembled per image. arm64 publishing is paused
|
||||||
|
until an arm64 runner with Docker buildx exists. Until a
|
||||||
stable `vX.Y.Z` has been published, GHCR may only have `edge` and `sha-*` tags; do not pin
|
stable `vX.Y.Z` has been published, GHCR may only have `edge` and `sha-*` tags; do not pin
|
||||||
`latest` unless that tag exists in the registry.
|
`latest` unless that tag exists in the registry.
|
||||||
|
|
||||||
|
|||||||
@ -19,31 +19,38 @@ const workflow = parse(workflowText) as {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("server image publish workflow", () => {
|
describe("server image publish workflow", () => {
|
||||||
it("builds every architecture natively instead of emulating arm64", () => {
|
it("builds amd64 natively on the Linux runner and keeps arm64 paused, not emulated", () => {
|
||||||
expect(workflowText).not.toContain("setup-qemu-action");
|
expect(workflowText).not.toContain("setup-qemu-action");
|
||||||
|
expect(workflowText).toContain("arm64 is paused");
|
||||||
const build = workflow.jobs.build;
|
const build = workflow.jobs.build;
|
||||||
expect(build["runs-on"]).toContain("matrix.runner");
|
expect(build["runs-on"]).toContain("matrix.runner");
|
||||||
expect(build.strategy?.matrix?.arch).toEqual(["amd64", "arm64"]);
|
expect(build.strategy?.matrix?.arch).toEqual(["amd64"]);
|
||||||
const runners = Object.fromEntries(
|
const runners = Object.fromEntries(
|
||||||
(build.strategy?.matrix?.include ?? [])
|
(build.strategy?.matrix?.include ?? [])
|
||||||
.filter((entry) => entry.arch !== undefined)
|
.filter((entry) => entry.arch !== undefined)
|
||||||
.map((entry) => [entry.arch, entry.runner]),
|
.map((entry) => [entry.arch, entry.runner]),
|
||||||
);
|
);
|
||||||
expect(runners).toEqual({
|
expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] });
|
||||||
amd64: ["self-hosted", "evoo-ci", "Linux"],
|
|
||||||
arm64: ["self-hosted", "evoo-ci", "macOS"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes one verified multi-arch manifest per image after both builds", () => {
|
it("publishes one verified manifest per image after the build", () => {
|
||||||
const publish = workflow.jobs.publish;
|
const publish = workflow.jobs.publish;
|
||||||
expect(publish.needs).toBe("build");
|
expect(publish.needs).toBe("build");
|
||||||
expect(workflowText).toContain("push-by-digest=true");
|
expect(workflowText).toContain("push-by-digest=true");
|
||||||
expect(workflowText).toContain("docker buildx imagetools create");
|
expect(workflowText).toContain("docker buildx imagetools create");
|
||||||
expect(workflowText).toContain("for want in linux/amd64 linux/arm64");
|
expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]');
|
||||||
|
expect(workflowText).toContain("for want in linux/amd64; do");
|
||||||
expect(workflowText).toContain("actions/attest-build-provenance@");
|
expect(workflowText).toContain("actions/attest-build-provenance@");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names every image under the lowercase repository namespace", () => {
|
||||||
|
expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}");
|
||||||
|
const references =
|
||||||
|
workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
|
||||||
|
expect(references.length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps pull requests read-only and every action pinned to a commit", () => {
|
it("keeps pull requests read-only and every action pinned to a commit", () => {
|
||||||
const validate = workflow.jobs.validate;
|
const validate = workflow.jobs.validate;
|
||||||
const build = workflow.jobs.build;
|
const build = workflow.jobs.build;
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||||
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
|
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
const playwrightWorkflow = readFileSync(
|
const playwrightWorkflow = readFileSync(
|
||||||
path.join(repoRoot, ".github/workflows/playwright.yml"),
|
path.join(repoRoot, ".github/workflows/playwright.yml"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@ -15,20 +18,62 @@ function portsFromShellArray(source: string, name: string): number[] {
|
|||||||
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
|
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs the script on a fake Linux host whose fuser never finds a listener, so nothing is killed. */
|
||||||
|
function runScript(env: Record<string, string>): { status: number; output: string } {
|
||||||
|
const fakeBin = mkdtempSync(path.join(tmpdir(), "evoo-e2e-ports-"));
|
||||||
|
for (const [name, body] of [
|
||||||
|
["uname", "echo Linux"],
|
||||||
|
["fuser", "exit 1"],
|
||||||
|
] as const) {
|
||||||
|
const file = path.join(fakeBin, name);
|
||||||
|
writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
|
||||||
|
chmodSync(file, 0o755);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const output = execFileSync("bash", [scriptPath], {
|
||||||
|
env: { PATH: `${fakeBin}:${process.env.PATH}`, ...env },
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return { status: 0, output };
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as { status: number; stdout: string; stderr: string };
|
||||||
|
return { status: failure.status, output: `${failure.stdout}${failure.stderr}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function announcedPorts(output: string): number[] {
|
||||||
|
const match = output.match(/E2E ports \(([^)]*)\)/);
|
||||||
|
expect(match, "announced E2E ports").toBeTruthy();
|
||||||
|
return match![1].split(" ").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
describe("free-e2e-ports.sh", () => {
|
describe("free-e2e-ports.sh", () => {
|
||||||
const script = readFileSync(scriptPath, "utf8");
|
it("keeps the live dev ports apart from the default E2E ports", () => {
|
||||||
|
|
||||||
it("declares dedicated E2E ports separate from live dev ports", () => {
|
|
||||||
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
|
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
|
||||||
const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
|
|
||||||
|
|
||||||
expect(livePorts).toEqual([3100, 5173]);
|
expect(livePorts).toEqual([3100, 5173]);
|
||||||
|
|
||||||
|
const run = runScript({});
|
||||||
|
expect(run.status).toBe(0);
|
||||||
|
const e2ePorts = announcedPorts(run.output);
|
||||||
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
|
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
|
||||||
for (const livePort of livePorts) {
|
for (const livePort of livePorts) {
|
||||||
expect(e2ePorts).not.toContain(livePort);
|
expect(e2ePorts).not.toContain(livePort);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows the harness port settings so runners on one host never touch each other", () => {
|
||||||
|
const run = runScript({ API_PORT: "3310", WEB_PORT: "5380", WWW_PORT: "4521" });
|
||||||
|
expect(run.status).toBe(0);
|
||||||
|
expect(announcedPorts(run.output)).toEqual([3310, 3311, 5380, 4521]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a port setting that is not a number", () => {
|
||||||
|
const run = runScript({ API_PORT: "abc" });
|
||||||
|
expect(run.status).toBe(1);
|
||||||
|
expect(run.output).toContain("API_PORT must be a port number");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses fuser and skips non-Linux hosts", () => {
|
it("uses fuser and skips non-Linux hosts", () => {
|
||||||
expect(script).toContain('fuser -k "$spec"');
|
expect(script).toContain('fuser -k "$spec"');
|
||||||
expect(script).toContain('uname -s)" != "Linux"');
|
expect(script).toContain('uname -s)" != "Linux"');
|
||||||
@ -44,4 +89,13 @@ describe("free-e2e-ports.sh", () => {
|
|||||||
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
|
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gets its ports from the workflow step that picks them per runner", () => {
|
||||||
|
const pickStep = playwrightWorkflow.indexOf("Pick E2E ports for this runner");
|
||||||
|
expect(pickStep).toBeGreaterThan(-1);
|
||||||
|
expect(pickStep).toBeLessThan(playwrightWorkflow.indexOf("free-e2e-ports.sh"));
|
||||||
|
for (const setting of ["API_PORT=", "WEB_PORT=", "WWW_PORT="]) {
|
||||||
|
expect(playwrightWorkflow).toContain(setting);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
|
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
|
||||||
# Never touches live dev stack ports 3100 (API) or 5173 (web).
|
# Never touches live dev stack ports 3100 (API) or 5173 (web).
|
||||||
|
# The ports follow API_PORT, WEB_PORT and WWW_PORT so that runners sharing one host,
|
||||||
|
# each with its own port set, never kill another job's servers.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
for setting in API_PORT WEB_PORT WWW_PORT; do
|
||||||
|
value="${!setting:-}"
|
||||||
|
if [[ -n "$value" && ! "$value" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "free-e2e-ports: $setting must be a port number, got: $value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
readonly LIVE_PORTS=(3100 5173)
|
readonly LIVE_PORTS=(3100 5173)
|
||||||
readonly E2E_PORTS=(3110 3111 5180 4321)
|
api_port="${API_PORT:-3110}"
|
||||||
|
readonly E2E_PORTS=("$api_port" "$((api_port + 1))" "${WEB_PORT:-5180}" "${WWW_PORT:-4321}")
|
||||||
|
|
||||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||||
echo "free-e2e-ports: skip (not Linux)"
|
echo "free-e2e-ports: skip (not Linux)"
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
|
# Publishing is optional: without the S3 settings the gallery is skipped and the job stays green.
|
||||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
|
for setting in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY S3_BUCKET S3_ENDPOINT PLAYWRIGHT_PUBLIC_BASE_URL; do
|
||||||
: "${S3_BUCKET:?S3_BUCKET is required}"
|
if [[ -z "${!setting:-}" ]]; then
|
||||||
: "${S3_ENDPOINT:?S3_ENDPOINT is required}"
|
echo "::notice::Skipping the Playwright report publish: $setting is not configured."
|
||||||
: "${PLAYWRIGHT_PUBLIC_BASE_URL:?PLAYWRIGHT_PUBLIC_BASE_URL is required}"
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
|
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
|
||||||
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
|
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
|
||||||
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"
|
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user