test: harden release and browser gates

This commit is contained in:
KoalaDev
2026-08-21 15:49:51 +02:00
parent 230e7f5932
commit 7286a6db3d
32 changed files with 1150 additions and 201 deletions
+45
View File
@@ -47,6 +47,36 @@ jobs:
- name: Run verification suite - name: Run verification suite
run: npm run verify run: npm run verify
node20:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up lowest supported Node.js
uses: actions/setup-node@v6
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run unit and relay integration gates
run: |
npm run test:coverage
node scripts/test-server-routes.mjs
node scripts/test-server-ws.mjs
env:
ADMIN_METRICS_TOKEN: verify-admin-token-with-more-than-32-chars
e2e: e2e:
# Kept separate from `verify`: this job needs a downloaded browser, so a # Kept separate from `verify`: this job needs a downloaded browser, so a
# failure here should read as "the browser flow broke", not as a broken # failure here should read as "the browser flow broke", not as a broken
@@ -66,6 +96,10 @@ jobs:
- name: Install root dependencies - name: Install root dependencies
run: npm ci run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Install Playwright browsers - name: Install Playwright browsers
run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit
@@ -75,3 +109,14 @@ jobs:
- name: Run cross-browser detection and extension E2E tests - name: Run cross-browser detection and extension E2E tests
run: npm run test:e2e run: npm run test:e2e
- name: Upload browser failure diagnostics
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e-failure-diagnostics
path: |
test-results/
playwright-report/
if-no-files-found: error
retention-days: 14
+5 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs: jobs:
extension-races: extension-races:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 60
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -30,6 +30,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Install Playwright Chromium - name: Install Playwright Chromium
run: npx playwright install --with-deps chromium chromium-headless-shell run: npx playwright install --with-deps chromium chromium-headless-shell
+156 -116
View File
@@ -5,14 +5,139 @@ on:
tags: tags:
- 'v*' - 'v*'
# A release run must never be interrupted (it commits back to main and publishes
# artifacts). Only dedupe accidental re-pushes of the same tag.
concurrency: concurrency:
group: release-${{ github.ref_name }} group: release-${{ github.ref_name }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
preflight:
runs-on: ubuntu-latest
permissions:
contents: read
checks: read
outputs:
version: ${{ steps.release-ref.outputs.version }}
tag-commit: ${{ steps.release-ref.outputs.tag_commit }}
steps:
- name: Checkout release tag
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Validate annotated tag, main commit, and required checks
id: release-ref
run: node scripts/release-preflight.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install root dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run complete release verification
run: npm run verify
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit
- name: Run browser E2E suite
run: npm run test:e2e
- name: Build relay container without publishing
run: docker build --file server/Dockerfile --tag koalasync-release-preflight .
- name: Smoke-test relay container
run: |
CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-preflight-salt-with-more-than-thirty-two-chars koalasync-release-preflight)
trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT
HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://')
for attempt in $(seq 1 30); do
if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then
exit 0
fi
sleep 1
done
docker logs "$CONTAINER_ID"
exit 1
release-extension-draft:
needs: preflight
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout release tag
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies and build release artifacts
run: |
npm ci
npm run build:extension
node website/build.cjs
- name: Validate Firefox package
run: npx addons-linter --warnings-as-errors dist/koalasync-firefox.zip
- name: Generate extension checksums
working-directory: dist
run: sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS
- name: Validate release assets before publication
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --asset-dir dist --skip-attestation
- name: Attest extension archives
uses: actions/attest@v4
with:
subject-path: dist/koalasync-*.zip
- name: Create draft GitHub release
uses: softprops/action-gh-release@v3
with:
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
dist/SHA256SUMS
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: true
prerelease: false
- name: Verify draft extension release
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload website artifacts
uses: actions/upload-artifact@v7
with:
name: website-www
path: website/www/
if-no-files-found: error
release-server: release-server:
needs: [preflight, release-extension-draft]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -20,7 +145,7 @@ jobs:
id-token: write id-token: write
attestations: write attestations: write
steps: steps:
- name: Checkout code - name: Checkout release tag
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -52,131 +177,46 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# Reuse layers across releases to speed up the multi-arch build.
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Generate artifact attestation - name: Attest relay image
uses: actions/attest@v4 uses: actions/attest@v4
with: with:
subject-name: ghcr.io/${{ github.repository }} subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }} subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true push-to-registry: true
release-extension: - name: Verify manifest, provenance, and running image
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE: ghcr.io/${{ github.repository }}
SOURCE_DIGEST: ${{ needs.preflight.outputs.tag-commit }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
docker buildx imagetools inspect "$IMAGE@$DIGEST" --raw > /tmp/koalasync-manifest.json
node -e 'const m=require("/tmp/koalasync-manifest.json"); const p=new Set(m.manifests.map(x=>`${x.platform.os}/${x.platform.architecture}`)); for (const x of ["linux/amd64","linux/arm64"]) if(!p.has(x)) throw new Error(`missing platform ${x}`)'
gh attestation verify "oci://$IMAGE@$DIGEST" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" --source-ref "$GITHUB_REF" --source-digest "$SOURCE_DIGEST" --deny-self-hosted-runners
docker pull --platform linux/amd64 "$IMAGE@$DIGEST"
CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-smoke-salt-with-more-than-thirty-two-chars "$IMAGE@$DIGEST")
trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT
HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://')
for attempt in $(seq 1 30); do
if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then
exit 0
fi
sleep 1
done
docker logs "$CONTAINER_ID"
exit 1
finalize-release:
needs: [preflight, release-extension-draft, release-server]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
id-token: write
attestations: write
steps: steps:
- name: Checkout code - name: Publish verified GitHub release
uses: actions/checkout@v7 run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Inject version into source files
run: |
VERSION=${{ steps.version.outputs.VERSION }}
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..."
# 1. extension/manifest.base.json
jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json
echo " ✓ manifest.base.json -> $VERSION"
# 2. shared/constants.js — APP_VERSION
sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js
echo " ✓ shared/constants.js -> $VERSION"
# 3. package.json
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo " ✓ package.json -> $VERSION"
# 4. package-lock.json root package metadata
jq --arg v "$VERSION" '.version = $v | .packages[""].version = $v' package-lock.json > tmp.json && mv tmp.json package-lock.json
echo " ✓ package-lock.json -> $VERSION"
# 5. website/version.json
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
echo " ✓ website/version.json -> version $VERSION, date $DATE"
# 6. website/template.html — SoftwareApplication schema
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
echo " ✓ website/template.html -> softwareVersion $VERSION"
# 7. website/llms.txt — machine-readable release metadata
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
echo " ✓ website/llms.txt -> $VERSION"
# 8. README.md — version badge & banner
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
echo " ✓ README.md -> v$VERSION"
echo "Version injection complete."
- name: Commit and push version updates back to main
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
git push origin HEAD:main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Extensions
run: |
npm ci
npm run build:extension
- name: Generate extension checksums
run: |
cd dist
sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS
cat SHA256SUMS
- name: Generate artifact attestation for extensions
uses: actions/attest@v4
with:
subject-path: dist/koalasync-*.zip
- name: Build Website
run: node website/build.cjs
- name: Upload Website Artifacts
uses: actions/upload-artifact@v7
with:
name: website-www
path: website/www/
if-no-files-found: error
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
dist/SHA256SUMS
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify published extension release
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -1
View File
@@ -96,7 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
## Version Numbers ## Version Numbers
> [!CAUTION] > [!CAUTION]
> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts. > **Never edit release versions independently.** Release maintainers run
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the
> resulting version changes through a CI-green pull request before tagging.
--- ---
+11 -5
View File
@@ -118,17 +118,23 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
> [!CAUTION] > [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.** > **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
> >
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures. > **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`.
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output. > - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file: 1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check. - **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check. - **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
2. Commit all verified code changes and push to `main`. 2. Commit the prepared version and release-note changes on a branch, push it,
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix. open a pull request, and wait for required `verify`, `node20`, and `e2e`
checks. Direct pushes to `main` are not part of the release process.
3. After the PR is merged, update local `main` and create an annotated exact
SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as
`origin/main`.
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0``v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds. - **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0``v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed. - **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
4. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release. 4. The release workflow validates the unchanged tagged source, creates a draft
5. Verify the release builds on GitHub Actions. release, publishes and verifies the relay image, and makes the release public
only after every gate succeeds.
5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
### 🚫 Force Push Policy ### 🚫 Force Push Policy
> [!CAUTION] > [!CAUTION]
+4 -1
View File
@@ -35,7 +35,10 @@ The build script performs the following actions:
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake. The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`. - The version is defined in `shared/constants.js`.
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error. - If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates. - Never edit release versions independently. Run
`npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch;
the release tag is accepted only after that change reaches `main` with all CI
checks passing.
> [!CAUTION] > [!CAUTION]
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script. > **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
+36 -23
View File
@@ -4,30 +4,32 @@ This document describes the deployment and release process for KoalaSync.
## Tag-Based Releases ## Tag-Based Releases
KoalaSync uses a fully automated release pipeline triggered by Git tags. KoalaSync uses a gated release pipeline triggered by immutable Git tags.
> [!IMPORTANT] > [!IMPORTANT]
> **DO NOT** manually bump the version numbers in any files (such as `package.json`, `manifest.base.json`, `shared/constants.js`, etc.) before creating a release. > **DO NOT** edit individual version files or tag an unmerged branch. Run
> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline. > `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated
> source changes, and merge them through a pull request with successful CI.
### How it Works ### How it Works
When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered. The workflow performs the following actions: When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub
Actions workflow performs these ordered gates:
1. **Extracts the version** from the tag (e.g., `2.5.1` from `v2.5.1`). 1. Confirms that the tag is annotated, points exactly at current `origin/main`,
2. **Injects the version** automatically into the following files: and matches every committed version source.
- `extension/manifest.base.json` 2. Requires successful `verify`, `node20`, and `e2e` checks for that commit.
- `shared/constants.js` (updates `APP_VERSION`) 3. Re-runs release verification, cross-browser E2E, and an unpublished relay
- `package.json` container smoke test.
- `package-lock.json` (root package metadata) 4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output,
- `website/version.json` website output, archive parity, and manifests.
- `website/template.html` (updates `softwareVersion` schema) 5. Creates an attested **draft** GitHub Release.
- `README.md` (updates badge and announcement banner) 6. Publishes the multi-architecture relay image, verifies both platforms,
- `website/sitemap.xml` (updates `lastmod` dates) attestation identity, digest, tag source, and a running health check.
3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`. 7. Makes the GitHub Release public only after every preceding gate succeeds.
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations.
5. **Builds the website** and uploads website artifacts. The release workflow never writes to `main` and never derives shell code from a
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`). tag. Version changes must pass normal branch protection first.
--- ---
@@ -35,18 +37,29 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea
To release a new version (e.g., `v2.5.1`), follow these steps: To release a new version (e.g., `v2.5.1`), follow these steps:
1. Make sure your local repository is synced on `main`: 1. Create a release-preparation branch from current `main` and update every
version source atomically:
```bash ```bash
git checkout main git checkout main
git pull origin main git pull origin main
git checkout -b release/v2.5.1
npm run prepare:release -- 2.5.1
npm run verify
``` ```
2. Create a local Git tag: 2. Commit the release notes and prepared version changes, open a pull request,
and wait for required `verify`, `node20`, and `e2e` checks.
3. After the PR is merged, fast-forward local `main` and create an **annotated**
tag on that exact commit:
```bash ```bash
git tag v2.5.1 git checkout main
git pull --ff-only origin main
git tag -a v2.5.1 -m "Release v2.5.1"
``` ```
3. Push the tag to GitHub: 4. Verify the tag target, then push it once:
```bash ```bash
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
git push origin v2.5.1 git push origin v2.5.1
``` ```
The release pipeline will take care of the rest! You can monitor the progress under the **Actions** tab of the GitHub repository. Never reuse or move a published tag. Monitor every release job and verify both
the public GitHub assets and GHCR digest before calling the release complete.
+42 -1
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
HOST_ACCESS_REQUIRED_STATUS, HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest, addTabHostAccessRequest,
@@ -16,6 +16,8 @@ import {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
describe('host access helpers', () => { describe('host access helpers', () => {
afterEach(() => vi.useRealTimers());
it('normalizes only positive safe tab IDs', () => { it('normalizes only positive safe tab IDs', () => {
expect(HOST_ACCESS_REQUIRED_STATUS).toBe('host_permission_required'); expect(HOST_ACCESS_REQUIRED_STATUS).toBe('host_permission_required');
for (const invalid of [null, undefined, '', 0, true, [42], '42.5', Number.MAX_SAFE_INTEGER + 1]) { for (const invalid of [null, undefined, '', 0, true, [42], '42.5', Number.MAX_SAFE_INTEGER + 1]) {
@@ -38,6 +40,11 @@ describe('host access helpers', () => {
}); });
expect(describeTabUrl('chrome://extensions/')).toBeNull(); expect(describeTabUrl('chrome://extensions/')).toBeNull();
expect(describeTabUrl('not a url')).toBeNull(); expect(describeTabUrl('not a url')).toBeNull();
expect(describeTabUrl('file:///Users/koala/movie.mp4')).toEqual({
url: 'file:///Users/koala/movie.mp4',
host: 'local file',
originPattern: 'file:///*'
});
}); });
it('checks the selected tab origin and preserves an unknown callback result', async () => { it('checks the selected tab origin and preserves an unknown callback result', async () => {
@@ -112,9 +119,43 @@ describe('host access helpers', () => {
}; };
await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true); await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true);
await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull(); await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull();
await expect(requestOriginPermission(callbackChrome, '')).resolves.toBeNull();
await expect(requestOriginPermission({
permissions: { request: async () => { throw new Error('denied'); } }
}, 'https://video.example/*')).resolves.toBe(false);
await expect(addTabHostAccessRequest({
permissions: { addHostAccessRequest: async () => { throw new Error('denied'); } }
}, 42)).resolves.toBe(false);
await expect(removeTabHostAccessRequest({
permissions: { removeHostAccessRequest: async () => { throw new Error('denied'); } }
}, 42)).resolves.toBe(false);
expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true); expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true);
expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false); expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false);
}); });
it('treats permission inspection errors and timeouts as advisory unknowns', async () => {
const base = {
tabs: { get: async () => ({ url: 'https://video.example/watch' }) }
};
await expect(inspectTabHostAccess({
...base,
permissions: { contains: async () => { throw new Error('permission API failed'); } }
}, 42)).resolves.toMatchObject({ granted: null });
await expect(inspectTabHostAccess({
...base,
runtime: { lastError: { message: 'permission callback failed' } },
permissions: { contains: (_request, callback) => callback(false) }
}, 42)).resolves.toMatchObject({ granted: null });
vi.useFakeTimers();
const pending = inspectTabHostAccess({
...base,
permissions: { contains: () => undefined }
}, 42);
await vi.advanceTimersByTimeAsync(1000);
await expect(pending).resolves.toMatchObject({ granted: null });
});
}); });
describe('host access recovery contracts', () => { describe('host access recovery contracts', () => {
+11 -11
View File
@@ -1413,16 +1413,16 @@
</a> </a>
</div> </div>
<div class="tabs"> <div class="tabs" role="tablist" aria-label="KoalaSync sections">
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button> <button id="tab-room-button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-room" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button> <button id="tab-sync-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-sync" tabindex="-1" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button> <button id="tab-settings-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-settings" tabindex="-1" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button> <button id="tab-dev-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-dev" tabindex="-1" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button> <button id="devToolsTabBtn" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-devtools" tabindex="-1" data-tab="tab-devtools" style="display:none;">Dev</button>
</div> </div>
<!-- Room Tab --> <!-- Room Tab -->
<div id="tab-room" class="tab-content active"> <div id="tab-room" class="tab-content active" role="tabpanel" aria-labelledby="tab-room-button">
<!-- JOIN SECTION: Visible when not in a room --> <!-- JOIN SECTION: Visible when not in a room -->
<div id="section-join"> <div id="section-join">
@@ -1510,7 +1510,7 @@
</div> </div>
<!-- Sync Tab --> <!-- Sync Tab -->
<div id="tab-sync" class="tab-content"> <div id="tab-sync" class="tab-content" role="tabpanel" aria-labelledby="tab-sync-button" aria-hidden="true">
<!-- SYNC ACTIVE: Visible when in a room --> <!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active"> <div id="sync-active">
<div class="form-group" style="position: relative;"> <div class="form-group" style="position: relative;">
@@ -1576,7 +1576,7 @@
</div> </div>
<!-- Settings Tab --> <!-- Settings Tab -->
<div id="tab-settings" class="tab-content"> <div id="tab-settings" class="tab-content" role="tabpanel" aria-labelledby="tab-settings-button" aria-hidden="true">
<details class="form-group" name="settings-accordion" open> <details class="form-group" name="settings-accordion" open>
<summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile &amp; Appearance</summary> <summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile &amp; Appearance</summary>
<div class="details-content"> <div class="details-content">
@@ -1813,7 +1813,7 @@
</div> </div>
<!-- Dev Tab --> <!-- Dev Tab -->
<div id="tab-dev" class="tab-content"> <div id="tab-dev" class="tab-content" role="tabpanel" aria-labelledby="tab-dev-button" aria-hidden="true">
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label> <label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;"> <div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span> <span id="connDot" class="status-dot status-offline"></span>
@@ -1845,7 +1845,7 @@
<div id="logList"></div> <div id="logList"></div>
</div> </div>
<div id="tab-devtools" class="tab-content"> <div id="tab-devtools" class="tab-content" role="tabpanel" aria-labelledby="devToolsTabBtn" aria-hidden="true">
<label>Remote Seek</label> <label>Remote Seek</label>
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;"> <div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button> <button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
+26 -2
View File
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
elements.tabs.forEach(btn => { elements.tabs.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
elements.tabs.forEach(b => b.classList.remove('active')); elements.tabs.forEach(b => {
elements.contents.forEach(c => c.classList.remove('active')); b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
b.tabIndex = -1;
});
elements.contents.forEach(c => {
c.classList.remove('active');
c.setAttribute('aria-hidden', 'true');
});
btn.classList.add('active'); btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
btn.tabIndex = 0;
const targetContent = document.getElementById(btn.dataset.tab); const targetContent = document.getElementById(btn.dataset.tab);
targetContent.classList.add('active'); targetContent.classList.add('active');
targetContent.removeAttribute('aria-hidden');
targetContent.classList.remove('tab-active-animate'); targetContent.classList.remove('tab-active-animate');
void targetContent.offsetWidth; // Force reflow to restart animation void targetContent.offsetWidth; // Force reflow to restart animation
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
chrome.storage.local.set({ activeTab: btn.dataset.tab }); chrome.storage.local.set({ activeTab: btn.dataset.tab });
}); });
btn.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const visibleTabs = [...elements.tabs].filter(tab => window.getComputedStyle(tab).display !== 'none');
const currentIndex = visibleTabs.indexOf(btn);
const nextIndex = event.key === 'Home'
? 0
: event.key === 'End'
? visibleTabs.length - 1
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + visibleTabs.length) % visibleTabs.length;
event.preventDefault();
visibleTabs[nextIndex].focus();
visibleTabs[nextIndex].click();
});
}); });
function showToast(message, type = 'info', duration = 3000) { function showToast(message, type = 'info', duration = 3000) {
+7
View File
@@ -21,13 +21,20 @@ describe('title privacy', () => {
expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube'); expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube');
expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube'); expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube');
expect(normalizeTabTitle('(500) Days of Summer')).toBe('Days of Summer'); expect(normalizeTabTitle('(500) Days of Summer')).toBe('Days of Summer');
for (const title of ['[7] Testvideo', '(99+) Testvideo', '(999+) Testvideo', '(101) Testvideo', '[101] Testvideo']) {
expect(normalizeTabTitle(title)).toBe('Testvideo');
}
expect(normalizeTabTitle(null)).toBeNull();
expect(normalizeTabTitle(' ')).toBeNull(); expect(normalizeTabTitle(' ')).toBeNull();
expect(sanitizeTabTitle('', true)).toBeNull();
}); });
it('keeps tab-title and media-title privacy independent', () => { it('keeps tab-title and media-title privacy independent', () => {
expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab'); expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab');
expect(sanitizeTabTitle('Private Tab', false)).toBeNull(); expect(sanitizeTabTitle('Private Tab', false)).toBeNull();
expect(sanitizeSharedTitle('Example Movie', 'full')).toBe('Example Movie'); expect(sanitizeSharedTitle('Example Movie', 'full')).toBe('Example Movie');
expect(sanitizeSharedTitle('', 'full')).toBeNull();
expect(sanitizeSharedTitle(null, 'full')).toBeNull();
expect(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode')).toBe('S01E04'); expect(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode')).toBe('S01E04');
expect(sanitizeSharedTitle('Folge 7 - Private Server', 'episode')).toBe('EP007'); expect(sanitizeSharedTitle('Folge 7 - Private Server', 'episode')).toBe('EP007');
expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull(); expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull();
+1 -1
View File
@@ -23,7 +23,7 @@
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
+2 -1
View File
@@ -5,13 +5,14 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.19.0"
}, },
"scripts": { "scripts": {
"build:extension": "node scripts/build-extension.cjs", "build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs", "indexnow": "node website/submit-indexnow.cjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"prepare:release": "node scripts/prepare-release.mjs",
"subset-flags": "node website/tools/subset-flag-font.mjs", "subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify", "test": "npm run verify",
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs", "test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
+10 -1
View File
@@ -10,6 +10,7 @@ npm run verify
npm run lint npm run lint
npm run test:unit npm run test:unit
npm run test:coverage npm run test:coverage
npm run prepare:release -- 3.1.5
``` ```
- `npm run build:extension` runs `scripts/build-extension.cjs`. - `npm run build:extension` runs `scripts/build-extension.cjs`.
@@ -17,6 +18,7 @@ npm run test:coverage
- `npm run lint` runs ESLint across the repository. - `npm run lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests. - `npm run test:unit` runs Vitest tests.
- `npm run test:coverage` runs the same tests with the enforced coverage floor. - `npm run test:coverage` runs the same tests with the enforced coverage floor.
- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
## build-extension.cjs ## build-extension.cjs
@@ -89,10 +91,17 @@ both global and risk-specific per-module floors. Browser entry points
(`background.js`, `content.js`, and `popup.js`) and server process startup are (`background.js`, `content.js`, and `popup.js`) and server process startup are
deliberately measured by extension E2E and integration tests instead of being deliberately measured by extension E2E and integration tests instead of being
reported as zero-coverage unit code. reported as zero-coverage unit code.
`scripts/check-coverage-inventory.mjs` additionally requires every JavaScript
source file to be classified as V8-covered or assigned to a named external
integration gate. New unclassified files fail `npm run verify`.
## Published Release Verification ## Published Release Verification
After a GitHub Release is created, the release workflow runs: Before publication, the release workflow validates the exact annotated SemVer
tag, requires it to point at current `origin/main`, requires successful
`verify`, `node20`, and `e2e` checks, and runs the complete gates again. It then
creates a draft release, publishes and smoke-tests the relay image, and only
afterwards makes the GitHub Release public. The published-asset gate runs:
```bash ```bash
node scripts/verify-published-release.mjs v3.1.4 --repo Shik3i/KoalaSync node scripts/verify-published-release.mjs v3.1.4 --repo Shik3i/KoalaSync
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EXTERNALLY_GATED_SOURCES, VITEST_COVERAGE_INCLUDE } from './coverage-plan.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE_ROOTS = Object.freeze(['extension', 'scripts', 'server', 'shared', 'website']);
const SOURCE_EXTENSION = /\.(?:cjs|js|mjs)$/u;
const TEST_FILE = /\.test\.(?:cjs|js|mjs)$/u;
const GENERATED_OR_DEPENDENCY_DIRECTORIES = new Set(['extension/shared', 'server/node_modules', 'website/www']);
export function validateCoverageInventory(discoveredSources, coveredSources, externallyGatedSources) {
const discovered = new Set(discoveredSources);
const assignments = [...coveredSources, ...externallyGatedSources];
const assigned = new Set();
const duplicates = new Set();
for (const source of assignments) {
if (assigned.has(source)) duplicates.add(source);
assigned.add(source);
}
const unclassified = [...discovered].filter(source => !assigned.has(source)).sort();
const stale = [...assigned].filter(source => !discovered.has(source)).sort();
if (duplicates.size || unclassified.length || stale.length) {
const details = [];
if (duplicates.size) details.push(`assigned more than once: ${[...duplicates].sort().join(', ')}`);
if (unclassified.length) details.push(`unclassified sources: ${unclassified.join(', ')}`);
if (stale.length) details.push(`stale assignments: ${stale.join(', ')}`);
throw new Error(details.join('; '));
}
}
function collectSources(directory, output = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
const relativeDirectory = path.relative(repoRoot, absolutePath).split(path.sep).join('/');
if (!GENERATED_OR_DEPENDENCY_DIRECTORIES.has(relativeDirectory)) collectSources(absolutePath, output);
} else if (SOURCE_EXTENSION.test(entry.name) && !TEST_FILE.test(entry.name)) {
output.push(path.relative(repoRoot, absolutePath).split(path.sep).join('/'));
}
}
return output;
}
function main() {
const discoveredSources = SOURCE_ROOTS.flatMap(root => collectSources(path.join(repoRoot, root))).sort();
const externallyGatedSources = Object.values(EXTERNALLY_GATED_SOURCES).flat();
validateCoverageInventory(discoveredSources, VITEST_COVERAGE_INCLUDE, externallyGatedSources);
console.log(`Coverage inventory passed: ${VITEST_COVERAGE_INCLUDE.length} V8-covered, ${externallyGatedSources.length} externally gated`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (error) {
console.error(`Coverage inventory failed: ${error.message}`);
process.exitCode = 1;
}
}
+68
View File
@@ -0,0 +1,68 @@
export const VITEST_COVERAGE_INCLUDE = Object.freeze([
'server/chat.js',
'server/ops.js',
'server/rate-limiter.js',
'shared/blacklist.js',
'shared/invite-links.js',
'shared/names.js',
'extension/chat-activity.js',
'extension/chat-crypto.js',
'extension/chat-format.js',
'extension/chat-session.js',
'extension/chat-wire.js',
'extension/episode-utils.js',
'extension/host-access.js',
'extension/media-frame-target.js',
'extension/title-privacy.js',
'scripts/release-artifact-checks.mjs'
]);
// Exact list by design: adding a runtime/tooling module requires choosing its
// automated gate instead of silently leaving it unmeasured.
export const EXTERNALLY_GATED_SOURCES = Object.freeze({
'packed extension E2E': Object.freeze([
'extension/audio-options.js',
'extension/background.js',
'extension/bridge.js',
'extension/chat-overlay.js',
'extension/content.js',
'extension/i18n.js',
'extension/media-frame-monitor.js',
'extension/modules/tab-manager.js',
'extension/page-api-seek-overrides.js',
'extension/popup.js',
'extension/theme-init.js',
'shared/constants.js'
]),
'relay integration': Object.freeze([
'server/index.js'
]),
'release and repository integration': Object.freeze([
'scripts/build-extension.cjs',
'scripts/check-coverage-inventory.mjs',
'scripts/coverage-plan.mjs',
'scripts/prepare-release.mjs',
'scripts/release-preflight.mjs',
'scripts/test-audio-settings.mjs',
'scripts/test-chat-settings.mjs',
'scripts/test-content-video-finder.cjs',
'scripts/test-locales.cjs',
'scripts/test-popup-refresh-cooldown.mjs',
'scripts/test-server-routes.mjs',
'scripts/test-server-ws.mjs',
'scripts/test-website-locales.mjs',
'scripts/test-website-theme.mjs',
'scripts/translate-locales-tool.cjs',
'scripts/validate-brand-names.cjs',
'scripts/verify-published-release.mjs',
'scripts/verify-release.mjs'
]),
'website build and contract checks': Object.freeze([
'website/app.js',
'website/build.cjs',
'website/flag-font-utils.cjs',
'website/lang-init.js',
'website/submit-indexnow.cjs',
'website/tools/subset-flag-font.mjs'
])
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { validateCoverageInventory } from './check-coverage-inventory.mjs';
describe('coverage inventory', () => {
it('accepts an exact, unique classification', () => {
expect(() => validateCoverageInventory(
['covered.js', 'browser.js'],
['covered.js'],
['browser.js']
)).not.toThrow();
});
it('rejects unclassified, stale, and duplicate assignments', () => {
expect(() => validateCoverageInventory(['new.js'], [], []))
.toThrow('unclassified sources: new.js');
expect(() => validateCoverageInventory([], ['deleted.js'], []))
.toThrow('stale assignments: deleted.js');
expect(() => validateCoverageInventory(['same.js'], ['same.js'], ['same.js']))
.toThrow('assigned more than once: same.js');
});
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { versionFromTag } from './release-artifact-checks.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function replaceExactly(text, pattern, replacement, label) {
const matches = String(text).match(pattern);
if (!matches || matches.length !== 1) {
throw new Error(`${label} must contain exactly one release-version marker`);
}
return text.replace(pattern, replacement);
}
function writeJson(relativePath, update) {
const absolutePath = path.join(repoRoot, relativePath);
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
update(value);
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function updateText(relativePath, pattern, replacement, label) {
const absolutePath = path.join(repoRoot, relativePath);
const current = fs.readFileSync(absolutePath, 'utf8');
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
}
export function prepareRelease(version, date = new Date()) {
versionFromTag(`v${version}`);
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
writeJson('package.json', value => { value.version = version; });
writeJson('package-lock.json', value => {
value.version = version;
value.packages[''].version = version;
});
writeJson('extension/manifest.base.json', value => { value.version = version; });
writeJson('website/version.json', value => {
value.version = version;
value.date = timestamp;
});
updateText(
'shared/constants.js',
/export const APP_VERSION = ["'][^"']+["'];/gu,
`export const APP_VERSION = "${version}";`,
'shared/constants.js'
);
updateText(
'website/template.html',
/"softwareVersion": "[^"]+"/gu,
`"softwareVersion": "${version}"`,
'website/template.html'
);
updateText(
'website/llms.txt',
/Current website release: .+/gu,
`Current website release: ${version}`,
'website/llms.txt'
);
updateText(
'README.md',
/Release-v\d+\.\d+\.\d+-blue/gu,
`Release-v${version}-blue`,
'README.md release badge'
);
console.log(`Prepared release v${version} at ${timestamp}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
if (process.argv.length !== 3) throw new Error('Usage: npm run prepare:release -- MAJOR.MINOR.PATCH');
prepareRelease(process.argv[2]);
} catch (error) {
console.error(`Release preparation failed: ${error.message}`);
process.exitCode = 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { replaceExactly } from './prepare-release.mjs';
describe('release preparation helpers', () => {
it('replaces one and only one version marker', () => {
expect(replaceExactly('version=3.1.4', /version=\d+\.\d+\.\d+/gu, 'version=3.1.5', 'fixture'))
.toBe('version=3.1.5');
expect(() => replaceExactly('none', /version=\d+/gu, 'version=4', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker');
expect(() => replaceExactly('version=1 version=2', /version=\d+/gu, 'version=3', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker');
});
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
export const REQUIRED_RELEASE_CHECKS = Object.freeze(['verify', 'node20', 'e2e']);
export function parseCheckRuns(text) {
return String(text).split(/\r?\n/u).filter(Boolean).map(line => {
const [name, conclusion, url = ''] = line.split('\t');
if (!name || !conclusion) throw new Error(`Invalid check-run record: ${line}`);
return { name, conclusion, url };
});
}
export function validateRequiredChecks(checkRuns, required = REQUIRED_RELEASE_CHECKS) {
for (const name of required) {
const matches = checkRuns.filter(check => check.name === name);
if (matches.length === 0) throw new Error(`Required check is missing for the release commit: ${name}`);
if (matches.some(check => check.conclusion !== 'success')) {
const conclusions = matches.map(check => check.conclusion).join(', ');
throw new Error(`Required check ${name} did not succeed: ${conclusions}`);
}
}
}
function run(command, args) {
return execFileSync(command, args, {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
export function validateRepositoryName(repo) {
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo || '')) {
throw new Error(`Invalid GitHub repository: ${repo || '<empty>'}`);
}
return repo;
}
export function validateVersionSnapshot(expectedVersion, snapshot) {
for (const [label, actualVersion] of Object.entries(snapshot)) {
if (actualVersion !== expectedVersion) {
throw new Error(`${label} version ${actualVersion || '<missing>'} does not match tag version ${expectedVersion}`);
}
}
}
export function validateReleaseSourceVersion(expectedVersion, repoRoot = process.cwd()) {
const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
const packageJson = readJson('package.json');
const packageLock = readJson('package-lock.json');
const manifest = readJson('extension/manifest.base.json');
const websiteVersion = readJson('website/version.json');
const constants = fs.readFileSync(path.join(repoRoot, 'shared/constants.js'), 'utf8');
const appVersion = /export const APP_VERSION = ["']([^"']+)["']/u.exec(constants)?.[1] || '';
validateVersionSnapshot(expectedVersion, {
'package.json': packageJson.version,
'package-lock.json': packageLock.version,
'package-lock root package': packageLock.packages?.['']?.version,
'extension manifest': manifest.version,
'shared constants': appVersion,
'website/version.json': websiteVersion.version
});
}
export function verifyReleaseRef({ tag, repo }) {
const version = versionFromTag(tag);
validateRepositoryName(repo);
validateReleaseSourceVersion(version);
const tagRef = `refs/tags/${tag}`;
if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') {
throw new Error(`${tag} must be an annotated tag`);
}
const tagCommit = run('git', ['rev-list', '-n', '1', tagRef]);
const mainCommit = run('git', ['rev-parse', 'origin/main']);
if (tagCommit !== mainCommit) {
throw new Error(`Release tag ${tag} points to ${tagCommit}, but origin/main is ${mainCommit}`);
}
const checks = parseCheckRuns(run('gh', [
'api', `repos/${repo}/commits/${tagCommit}/check-runs`,
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]));
validateRequiredChecks(checks);
return { version, tagCommit };
}
function main() {
const tag = process.env.GITHUB_REF_NAME || '';
const repo = process.env.GITHUB_REPOSITORY || '';
const outputPath = process.env.GITHUB_OUTPUT || '';
const result = verifyReleaseRef({ tag, repo });
if (!outputPath) throw new Error('GITHUB_OUTPUT is required');
fs.appendFileSync(outputPath, `version=${result.version}\ntag_commit=${result.tagCommit}\n`, 'utf8');
console.log(`Release preflight accepted ${tag} at ${result.tagCommit}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (error) {
console.error(`Release preflight failed: ${error.message}`);
process.exitCode = 1;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
parseCheckRuns,
validateRepositoryName,
validateRequiredChecks,
validateVersionSnapshot
} from './release-preflight.mjs';
describe('release preflight helpers', () => {
it('parses successful GitHub check runs', () => {
const checks = parseCheckRuns('verify\tsuccess\thttps://example.test/1\nnode20\tsuccess\thttps://example.test/2\ne2e\tsuccess\thttps://example.test/3');
expect(checks).toEqual([
{ name: 'verify', conclusion: 'success', url: 'https://example.test/1' },
{ name: 'node20', conclusion: 'success', url: 'https://example.test/2' },
{ name: 'e2e', conclusion: 'success', url: 'https://example.test/3' }
]);
expect(() => validateRequiredChecks(checks)).not.toThrow();
});
it('rejects missing, pending, and failed release checks', () => {
expect(() => validateRequiredChecks([{ name: 'verify', conclusion: 'success' }]))
.toThrow('Required check is missing for the release commit: node20');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'in_progress' }
])).toThrow('Required check e2e did not succeed: in_progress');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'success' }
])).toThrow('Required check verify did not succeed: failure');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' },
{ name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'success' }
])).toThrow('Required check verify did not succeed: success, failure');
});
it('validates repository names and malformed check output', () => {
expect(validateRepositoryName('Shik3i/KoalaSync')).toBe('Shik3i/KoalaSync');
for (const invalid of ['', 'KoalaSync', 'owner/repo/extra', 'owner /repo']) {
expect(() => validateRepositoryName(invalid)).toThrow('Invalid GitHub repository');
}
expect(() => parseCheckRuns('verify')).toThrow('Invalid check-run record');
});
it('requires every release source to already match the tag version', () => {
expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5',
manifest: '3.1.5'
})).not.toThrow();
expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5',
manifest: '3.1.4'
})).toThrow('manifest version 3.1.4 does not match tag version 3.1.5');
});
});
+10 -2
View File
@@ -85,7 +85,8 @@ async function verify() {
if (run('git', ['cat-file', '-t', tagRef]).trim() !== 'tag') { if (run('git', ['cat-file', '-t', tagRef]).trim() !== 'tag') {
throw new Error(`${options.tag} must be an annotated tag`); throw new Error(`${options.tag} must be an annotated tag`);
} }
run('git', ['merge-base', '--is-ancestor', tagRef, 'HEAD']); run('git', ['merge-base', '--is-ancestor', tagRef, 'origin/main']);
const tagCommit = run('git', ['rev-list', '-n', '1', tagRef]).trim();
const temporaryDirectory = options.assetDir const temporaryDirectory = options.assetDir
? null ? null
@@ -132,7 +133,14 @@ async function verify() {
validateManifest(browserName, manifest, version); validateManifest(browserName, manifest, version);
assertRuntimeBuild(browserName, archivePath, version); assertRuntimeBuild(browserName, archivePath, version);
if (!options.skipAttestation && !options.assetDir) { if (!options.skipAttestation && !options.assetDir) {
run('gh', ['attestation', 'verify', archivePath, '--repo', repo], { capture: false }); run('gh', [
'attestation', 'verify', archivePath,
'--repo', repo,
'--signer-workflow', `${repo}/.github/workflows/release.yml`,
'--source-ref', tagRef,
'--source-digest', tagCommit,
'--deny-self-hosted-runners'
], { capture: false });
} }
} }
validateArchiveParity(archiveEntries.chrome, archiveEntries.firefox); validateArchiveParity(archiveEntries.chrome, archiveEntries.firefox);
+1
View File
@@ -7,6 +7,7 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [ const checks = [
['coverage source inventory', 'node', ['scripts/check-coverage-inventory.mjs']],
['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']], ['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], { ['server routes', 'node', ['scripts/test-server-routes.mjs'], {
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' } env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
+56 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { import {
checkAdminMetricsAuthRate, checkAdminMetricsAuthRate,
checkAuthRate, checkAuthRate,
@@ -12,6 +12,11 @@ import {
CHAT_MESSAGE_RATE_LIMIT, CHAT_MESSAGE_RATE_LIMIT,
CHAT_MESSAGE_RATE_WINDOW_MS, CHAT_MESSAGE_RATE_WINDOW_MS,
chatMessageCounts, chatMessageCounts,
connectionCounts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
failedAuthAttempts, failedAuthAttempts,
LEAVE_ROOM_RATE_LIMIT, LEAVE_ROOM_RATE_LIMIT,
LEAVE_ROOM_RATE_WINDOW_MS, LEAVE_ROOM_RATE_WINDOW_MS,
@@ -180,6 +185,56 @@ describe('remaining relay rate limits', () => {
stopRateLimitCleanup(); stopRateLimitCleanup();
}).not.toThrow(); }).not.toThrow();
}); });
it('clears every rate-limit map', () => {
const maps = [
connectionCounts,
failedAuthAttempts,
eventCounts,
chatMessageCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
leaveRoomCounts
];
maps.forEach((map, index) => map.set(`key-${index}`, { count: 1 }));
clearRateLimitMaps();
maps.forEach(map => expect(map.size).toBe(0));
});
it('removes expired and disconnected entries in both cleanup intervals', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-21T10:00:00Z'));
const now = Date.now();
const sockets = new Map([['connected', {}]]);
connectionCounts.set('expired-ip', { count: 1, resetTime: now - 1 });
connectionCounts.set('live-ip', { count: 1, resetTime: now + 120000 });
eventCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
eventCounts.set('connected', { count: 1, resetTime: now + 120000 });
chatMessageCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
leaveRoomCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
healthCounts.set('expired-health', { count: 1, resetTime: now - 1 });
adminMetricsAuthCounts.set('expired-admin', { count: 1, resetTime: now - 1 });
roomListCooldowns.set('disconnected', now);
roomListCooldowns.set('connected', now);
failedAuthAttempts.set('expired-auth', { count: 1, lastAttempt: now - (16 * 60 * 1000) });
failedAuthAttempts.set('live-auth', { count: 1, lastAttempt: now });
startRateLimitCleanup({ sockets: { sockets } });
await vi.advanceTimersByTimeAsync(60000);
expect([...connectionCounts.keys()]).toEqual(['live-ip']);
expect([...eventCounts.keys()]).toEqual(['connected']);
expect(chatMessageCounts.size).toBe(0);
expect(leaveRoomCounts.size).toBe(0);
expect(healthCounts.size).toBe(0);
expect(adminMetricsAuthCounts.size).toBe(0);
expect([...roomListCooldowns.keys()]).toEqual(['connected']);
await vi.advanceTimersByTimeAsync(14 * 60 * 1000);
expect([...failedAuthAttempts.keys()]).toEqual(['live-auth']);
stopRateLimitCleanup();
vi.useRealTimers();
});
}); });
describe('Rate Limit Constants', () => { describe('Rate Limit Constants', () => {
+2
View File
@@ -19,6 +19,8 @@ npm run test:e2e:race # @race scenarios, repeated 20 times
| :--- | :--- | | :--- | :--- |
| `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages | | `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages |
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek | | `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
| `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery |
| `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup |
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media | | `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
| `fixtures/pages/` | One page per scenario | | `fixtures/pages/` | One page per scenario |
| `fixtures/media/` | Small generated clips (see below) | | `fixtures/media/` | Small generated clips (see below) |
+10 -10
View File
@@ -263,7 +263,7 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
).toBeGreaterThan(5); ).toBeGreaterThan(5);
}); });
test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
const first = `${baseURL}/pages/iframe-player.html`; const first = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(first); await page.goto(first);
@@ -286,7 +286,7 @@ test('reinjects after the target tab navigates', async ({ context, extensionId,
).toBe('true'); ).toBe('true');
}); });
test('re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => { test('@race re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/reloading-frame.html`; const url = `${baseURL}/pages/reloading-frame.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -313,7 +313,7 @@ test('re-attaches after the player frame swaps its document', async ({ context,
).toBe('true'); ).toBe('true');
}); });
test('re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => { test('@race re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/nested-frame.html`; const url = `${baseURL}/pages/nested-frame.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -342,7 +342,7 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
).toBe('true'); ).toBe('true');
}); });
test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => { test('@race moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/player-css-switch.html`; const url = `${baseURL}/pages/player-css-switch.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -451,7 +451,7 @@ test('targets a visible nested cross-origin player and keeps top-page debug cont
}); });
}); });
test('re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => { test('@race re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`; const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -476,7 +476,7 @@ test('re-elects the visible cross-origin player after an iframe switch', async (
expect(await first.locator('video').evaluate(video => video.paused)).toBe(true); expect(await first.locator('video').evaluate(video => video.paused)).toBe(true);
}); });
test('immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => { test('@race immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`; const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -530,7 +530,7 @@ test('keeps commands flowing during continuous player-frame geometry changes', a
} }
}); });
test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => { test('@race deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`; const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
const secondUrl = `${baseURL}/pages/simple-player.html`; const secondUrl = `${baseURL}/pages/simple-player.html`;
const firstPage = await context.newPage(); const firstPage = await context.newPage();
@@ -580,7 +580,7 @@ test('re-attaches after a selected cross-origin frame navigates', async ({ conte
expect(state).toMatchObject({ found: true, inIframe: true }); expect(state).toMatchObject({ found: true, inIframe: true });
}); });
test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => { test('@race discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-late.html`; const url = `${baseURL}/pages/cross-origin-late.html`;
const page = await context.newPage(); const page = await context.newPage();
await page.goto(url); await page.goto(url);
@@ -668,7 +668,7 @@ test('selects the visible anime player nested behind a same-origin wrapper', asy
}); });
}); });
test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => { test('@race selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
// The live case: at selection time the page has no video anywhere, because // The live case: at selection time the page has no video anywhere, because
// the host only builds the player when the viewer presses play. // the host only builds the player when the viewer presses play.
const url = `${baseURL}/pages/yummy-deferred-player.html`; const url = `${baseURL}/pages/yummy-deferred-player.html`;
@@ -804,7 +804,7 @@ test('controls and adopts a nested player even while the top frame is elected',
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true }); expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true });
}); });
test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => { test('@race recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000); test.setTimeout(90000);
// Kodik rebuilds its player frame on quality and part changes, which kills // Kodik rebuilds its player frame on quality and part changes, which kills
// the documentId the election is pinned to. The election has to be given up, // the documentId the election is pinned to. The election has to be given up,
+87 -17
View File
@@ -7,30 +7,83 @@ import { test as base, chromium } from '@playwright/test';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export const extensionPath = path.join(repoRoot, 'dist/chrome'); export const extensionPath = path.join(repoRoot, 'dist/chrome');
function isLocalTestUrl(rawUrl) {
try {
const url = new URL(rawUrl);
if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) return true;
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
} catch (_error) {
return false;
}
}
export async function launchExtensionContext() {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
let context;
try {
context = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required',
'--host-resolver-rules=MAP * 0.0.0.0, EXCLUDE localhost, EXCLUDE 127.0.0.1'
]
});
await context.route('**/*', route => {
if (isLocalTestUrl(route.request().url())) return route.continue();
return route.abort('blockedbyclient');
});
if (typeof context.routeWebSocket === 'function') {
await context.routeWebSocket(/.*/u, webSocketRoute => {
if (isLocalTestUrl(webSocketRoute.url())) {
webSocketRoute.connectToServer();
} else {
webSocketRoute.close({ code: 1008, reason: 'External network blocked by E2E harness' });
}
});
}
let [worker] = context.serviceWorkers();
if (!worker) worker = await context.waitForEvent('serviceworker');
const extensionId = worker.url().split('/')[2];
return {
context,
extensionId,
async close() {
try {
await context.close();
} finally {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
};
} catch (error) {
if (context) await context.close().catch(() => {});
if (fs.existsSync(userDataDir)) {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
throw error;
}
}
/** /**
* A browser with the packed extension loaded, plus its extension id. Each test * A browser with the packed extension loaded, plus its extension id. Each test
* gets a throwaway profile so storage from one test cannot leak into the next. * gets a throwaway profile so storage from one test cannot leak into the next.
*/ */
export const test = base.extend({ export const test = base.extend({
context: async ({}, use) => { context: async ({}, use) => {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) { // The headless shell does not run MV3 service workers; the full
throw new Error('dist/chrome is missing. Run: npm run build:extension'); // Chromium build in new headless mode does.
const launched = await launchExtensionContext();
try {
await use(launched.context);
} finally {
await launched.close();
} }
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
const context = await chromium.launchPersistentContext(userDataDir, {
// The headless shell does not run MV3 service workers; the full
// Chromium build in new headless mode does.
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required'
]
});
await use(context);
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
}, },
extensionId: async ({ context }, use) => { extensionId: async ({ context }, use) => {
let [worker] = context.serviceWorkers(); let [worker] = context.serviceWorkers();
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
export async function writeStorage(page, values) { export async function writeStorage(page, values) {
return page.evaluate(v => chrome.storage.local.set(v), values); return page.evaluate(v => chrome.storage.local.set(v), values);
} }
export async function terminateServiceWorker(context, extensionId) {
const page = await context.newPage();
let session;
try {
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
session = await context.newCDPSession(page);
const { targetInfos } = await session.send('Target.getTargets');
const worker = targetInfos.find(target => target.type === 'service_worker'
&& target.url.startsWith(`chrome-extension://${extensionId}/`));
if (!worker) throw new Error(`service worker target missing for ${extensionId}`);
await session.send('Target.closeTarget', { targetId: worker.targetId });
} finally {
if (session) await session.detach().catch(() => {});
await page.close().catch(() => {});
}
}
+62
View File
@@ -0,0 +1,62 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export async function reservePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : null;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
if (!port) throw new Error('failed to reserve relay port');
return port;
}
export async function startRelay(port) {
const output = [];
const child = spawn(process.execPath, ['server/index.js'], {
cwd: repoRoot,
env: {
...process.env,
PORT: String(port),
SERVER_SALT: 'koalasync-e2e-relay-salt-with-more-than-thirty-two-chars'
},
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.on('data', chunk => output.push(String(chunk)));
child.stderr.on('data', chunk => output.push(String(chunk)));
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`relay exited with ${child.exitCode}: ${output.join('')}`);
}
try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
if (response.ok) return { child, output };
} catch (_error) {
// Relay is still starting.
}
await new Promise(resolve => setTimeout(resolve, 100));
}
child.kill('SIGTERM');
throw new Error(`relay did not become healthy: ${output.join('')}`);
}
export async function stopRelay(relay) {
if (!relay || relay.child.exitCode !== null) return;
relay.child.kill('SIGTERM');
const stopped = await Promise.race([
new Promise(resolve => relay.child.once('exit', () => resolve(true))),
new Promise(resolve => setTimeout(() => resolve(false), 7000))
]);
if (stopped) return;
relay.child.kill('SIGKILL');
await new Promise(resolve => relay.child.once('exit', resolve));
throw new Error(`relay required SIGKILL: ${relay.output.join('')}`);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
fullyParallel: false, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: 0, retries: 0,
reporter: process.env.CI ? 'list' : [['list']], reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
timeout: 30_000, timeout: 30_000,
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
use: { use: {
+29
View File
@@ -0,0 +1,29 @@
import { expect, openPopup, test } from './helpers/extension-fixture.mjs';
test('popup exposes names and keyboard access for every visible control', async ({ context, extensionId }) => {
const page = await openPopup(context, extensionId, { openEditor: false });
const unnamed = await page.locator('button, input, select, textarea, a[href]').evaluateAll(elements => elements
.filter(element => {
const style = window.getComputedStyle(element);
return !element.disabled && style.display !== 'none' && style.visibility !== 'hidden'
&& element.getClientRects().length > 0;
})
.filter(element => {
const label = element.getAttribute('aria-label')
|| element.getAttribute('aria-labelledby')
|| element.getAttribute('title')
|| element.labels?.[0]?.textContent
|| element.textContent;
return !String(label || '').trim();
})
.map(element => `${element.tagName.toLowerCase()}#${element.id || '<no-id>'}`));
expect(unnamed).toEqual([]);
await page.locator('body').press('Tab');
await expect(page.locator(':focus')).not.toHaveCount(0);
const settingsTab = page.locator('.tab-btn[data-tab="tab-settings"]');
await settingsTab.focus();
await page.keyboard.press('Enter');
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#tab-settings')).toBeVisible();
});
+117
View File
@@ -0,0 +1,117 @@
import {
expect,
launchExtensionContext,
terminateServiceWorker,
test
} from './helpers/extension-fixture.mjs';
import { reservePort, startRelay, stopRelay } from './helpers/relay-process.mjs';
// popup.html intentionally performs connection setup. Use a neutral extension
// page for privileged test messages so opening the test transport cannot race
// the server settings being exercised.
async function withExtensionPage(context, extensionId, fn) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
try {
return await fn(page);
} finally {
await page.close();
}
}
function getStatus(context, extensionId) {
return withExtensionPage(context, extensionId, page => page.evaluate(
() => chrome.runtime.sendMessage({ type: 'GET_STATUS' })
));
}
async function selectTarget(context, extensionId, url) {
return withExtensionPage(context, extensionId, page => page.evaluate(async targetUrl => {
const [tab] = await chrome.tabs.query({ url: targetUrl });
if (!tab) throw new Error(`target tab missing: ${targetUrl}`);
const result = await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id, tabTitle: tab.title });
return { tabId: tab.id, result };
}, url));
}
async function connect(context, extensionId, { relayUrl, roomId, username }) {
await withExtensionPage(context, extensionId, page => page.evaluate(async settings => {
await chrome.storage.local.set({
roomId: settings.roomId,
password: '',
chatKey: '',
username: settings.username,
useCustomServer: true,
serverUrl: settings.relayUrl
});
await chrome.runtime.sendMessage({ type: 'CONNECT' });
}, { relayUrl, roomId, username }));
}
test('@race synchronizes two packed clients across relay and service-worker restarts', async ({ context, extensionId, baseURL }) => {
test.setTimeout(120000);
const second = await launchExtensionContext();
const port = await reservePort();
let relay = await startRelay(port);
try {
const firstUrl = `${baseURL}/pages/simple-player.html?client=first`;
const secondUrl = `${baseURL}/pages/simple-player.html?client=second`;
const firstPage = await context.newPage();
const secondPage = await second.context.newPage();
await Promise.all([firstPage.goto(firstUrl), secondPage.goto(secondUrl)]);
await Promise.all([
firstPage.waitForFunction(() => window.__fixtureReady === true),
secondPage.waitForFunction(() => window.__fixtureReady === true)
]);
await selectTarget(context, extensionId, firstUrl);
await selectTarget(second.context, second.extensionId, secondUrl);
const connection = { relayUrl: `ws://127.0.0.1:${port}`, roomId: 'E2E-ROOM-42' };
await connect(context, extensionId, { ...connection, username: 'First' });
await expect.poll(() => getStatus(context, extensionId)).toMatchObject({
status: 'connected',
roomId: connection.roomId,
peers: [expect.objectContaining({ username: 'First' })]
});
await connect(second.context, second.extensionId, { ...connection, username: 'Second' });
let latestStates = [];
try {
await expect.poll(async () => {
latestStates = await Promise.all([
getStatus(context, extensionId),
getStatus(second.context, second.extensionId)
]);
return latestStates.map(state => state.peers.length);
}).toEqual([2, 2]);
} catch (error) {
console.error(`Two-client join diagnostics: ${JSON.stringify(latestStates)}`);
console.error(`Relay diagnostics: ${relay.output.join('')}`);
throw error;
}
for (const state of latestStates) expect(state.serverUrl).toBe(connection.relayUrl);
await firstPage.locator('video').evaluate(video => video.play());
await expect.poll(() => secondPage.locator('video').evaluate(video => video.paused)).toBe(false);
await firstPage.locator('video').evaluate(video => video.pause());
await expect.poll(() => secondPage.locator('video').evaluate(video => video.paused)).toBe(true);
await stopRelay(relay);
relay = null;
await expect.poll(() => getStatus(context, extensionId).then(status => status.status))
.not.toBe('connected');
relay = await startRelay(port);
await expect.poll(() => Promise.all([
getStatus(context, extensionId),
getStatus(second.context, second.extensionId)
]).then(states => states.map(state => state.status)), { timeout: 45000 }).toEqual(['connected', 'connected']);
await terminateServiceWorker(second.context, second.extensionId);
await expect.poll(() => getStatus(second.context, second.extensionId), { timeout: 45000 })
.toMatchObject({ status: 'connected', roomId: connection.roomId });
await expect.poll(() => getStatus(context, extensionId).then(status => status.peers.length), { timeout: 45000 })
.toBe(2);
} finally {
await stopRelay(relay);
await second.close();
}
});
+2 -6
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { VITEST_COVERAGE_INCLUDE } from './scripts/coverage-plan.mjs';
export default defineConfig({ export default defineConfig({
test: { test: {
@@ -20,12 +21,7 @@ export default defineConfig({
// by Vitest. Browser entry points and subprocess integration tests // by Vitest. Browser entry points and subprocess integration tests
// have separate E2E/integration gates and must not be reported as // have separate E2E/integration gates and must not be reported as
// zero-coverage unit-test targets. // zero-coverage unit-test targets.
include: [ include: VITEST_COVERAGE_INCLUDE,
'server/{chat,ops,rate-limiter}.js',
'shared/{blacklist,invite-links,names}.js',
'extension/{chat-activity,chat-crypto,chat-format,chat-session,chat-wire,episode-utils,host-access,media-frame-target,title-privacy}.js',
'scripts/release-artifact-checks.mjs'
],
exclude: [ exclude: [
'**/node_modules/**' '**/node_modules/**'
], ],