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
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:
# 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
@@ -66,6 +96,10 @@ jobs:
- name: Install root dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Install Playwright browsers
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
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:
extension-races:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v7
@@ -30,6 +30,10 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium chromium-headless-shell
+156 -116
View File
@@ -5,14 +5,139 @@ on:
tags:
- '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:
group: release-${{ github.ref_name }}
cancel-in-progress: false
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:
needs: [preflight, release-extension-draft]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -20,7 +145,7 @@ jobs:
id-token: write
attestations: write
steps:
- name: Checkout code
- name: Checkout release tag
uses: actions/checkout@v7
- name: Set up Docker Buildx
@@ -52,131 +177,46 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Reuse layers across releases to speed up the multi-arch build.
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
- name: Attest relay image
uses: actions/attest@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
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
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
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: 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"
- name: Publish verified GitHub release
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag
env:
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
> [!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]
> **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.
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.
- **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`.
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.
2. Commit the prepared version and release-note changes on a branch, push it,
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.
- **🚫 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.
5. Verify the release builds on GitHub Actions.
4. The release workflow validates the unchanged tagged source, creates a draft
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
> [!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 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.
- **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]
> **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
KoalaSync uses a fully automated release pipeline triggered by Git tags.
KoalaSync uses a gated release pipeline triggered by immutable Git tags.
> [!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.
> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline.
> **DO NOT** edit individual version files or tag an unmerged branch. Run
> `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
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`).
2. **Injects the version** automatically into the following files:
- `extension/manifest.base.json`
- `shared/constants.js` (updates `APP_VERSION`)
- `package.json`
- `package-lock.json` (root package metadata)
- `website/version.json`
- `website/template.html` (updates `softwareVersion` schema)
- `README.md` (updates badge and announcement banner)
- `website/sitemap.xml` (updates `lastmod` dates)
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]`.
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.
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`).
1. Confirms that the tag is annotated, points exactly at current `origin/main`,
and matches every committed version source.
2. Requires successful `verify`, `node20`, and `e2e` checks for that commit.
3. Re-runs release verification, cross-browser E2E, and an unpublished relay
container smoke test.
4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output,
website output, archive parity, and manifests.
5. Creates an attested **draft** GitHub Release.
6. Publishes the multi-architecture relay image, verifies both platforms,
attestation identity, digest, tag source, and a running health check.
7. Makes the GitHub Release public only after every preceding gate succeeds.
The release workflow never writes to `main` and never derives shell code from a
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:
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
git checkout 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
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
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
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 path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
@@ -16,6 +16,8 @@ import {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
describe('host access helpers', () => {
afterEach(() => vi.useRealTimers());
it('normalizes only positive safe tab IDs', () => {
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]) {
@@ -38,6 +40,11 @@ describe('host access helpers', () => {
});
expect(describeTabUrl('chrome://extensions/')).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 () => {
@@ -112,9 +119,43 @@ describe('host access helpers', () => {
};
await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true);
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('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', () => {
+11 -11
View File
@@ -1413,16 +1413,16 @@
</a>
</div>
<div class="tabs">
<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 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 class="tab-btn" 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="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
<div class="tabs" role="tablist" aria-label="KoalaSync sections">
<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 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 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 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" role="tab" aria-selected="false" aria-controls="tab-devtools" tabindex="-1" data-tab="tab-devtools" style="display:none;">Dev</button>
</div>
<!-- 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 -->
<div id="section-join">
@@ -1510,7 +1510,7 @@
</div>
<!-- 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 -->
<div id="sync-active">
<div class="form-group" style="position: relative;">
@@ -1576,7 +1576,7 @@
</div>
<!-- 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>
<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">
@@ -1813,7 +1813,7 @@
</div>
<!-- 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>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
@@ -1845,7 +1845,7 @@
<div id="logList"></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>
<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>
+26 -2
View File
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
elements.tabs.forEach(b => b.classList.remove('active'));
elements.contents.forEach(c => c.classList.remove('active'));
elements.tabs.forEach(b => {
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.setAttribute('aria-selected', 'true');
btn.tabIndex = 0;
const targetContent = document.getElementById(btn.dataset.tab);
targetContent.classList.add('active');
targetContent.removeAttribute('aria-hidden');
targetContent.classList.remove('tab-active-animate');
void targetContent.offsetWidth; // Force reflow to restart animation
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
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) {
+7
View File
@@ -21,13 +21,20 @@ describe('title privacy', () => {
expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube');
expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube');
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(sanitizeTabTitle('', true)).toBeNull();
});
it('keeps tab-title and media-title privacy independent', () => {
expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab');
expect(sanitizeTabTitle('Private Tab', false)).toBeNull();
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('Folge 7 - Private Server', 'episode')).toBe('EP007');
expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull();
+1 -1
View File
@@ -23,7 +23,7 @@
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.9.0"
"node": ">=20.19.0"
}
},
"node_modules/@babel/code-frame": {
+2 -1
View File
@@ -5,13 +5,14 @@
"private": true,
"type": "module",
"engines": {
"node": ">=20.9.0"
"node": ">=20.19.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepare:release": "node scripts/prepare-release.mjs",
"subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify",
"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 test:unit
npm run test:coverage
npm run prepare:release -- 3.1.5
```
- `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 test:unit` runs Vitest tests.
- `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
@@ -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
deliberately measured by extension E2E and integration tests instead of being
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
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
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') {
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
? null
@@ -132,7 +133,14 @@ async function verify() {
validateManifest(browserName, manifest, version);
assertRuntimeBuild(browserName, archivePath, version);
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);
+1
View File
@@ -7,6 +7,7 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
['coverage source inventory', 'node', ['scripts/check-coverage-inventory.mjs']],
['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
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 {
checkAdminMetricsAuthRate,
checkAuthRate,
@@ -12,6 +12,11 @@ import {
CHAT_MESSAGE_RATE_LIMIT,
CHAT_MESSAGE_RATE_WINDOW_MS,
chatMessageCounts,
connectionCounts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
failedAuthAttempts,
LEAVE_ROOM_RATE_LIMIT,
LEAVE_ROOM_RATE_WINDOW_MS,
@@ -180,6 +185,56 @@ describe('remaining relay rate limits', () => {
stopRateLimitCleanup();
}).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', () => {
+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 |
| `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 |
| `fixtures/pages/` | One page per scenario |
| `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);
});
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 page = await context.newPage();
await page.goto(first);
@@ -286,7 +286,7 @@ test('reinjects after the target tab navigates', async ({ context, extensionId,
).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 page = await context.newPage();
await page.goto(url);
@@ -313,7 +313,7 @@ test('re-attaches after the player frame swaps its document', async ({ context,
).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 page = await context.newPage();
await page.goto(url);
@@ -342,7 +342,7 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
).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 page = await context.newPage();
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 page = await context.newPage();
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);
});
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 page = await context.newPage();
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 secondUrl = `${baseURL}/pages/simple-player.html`;
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 });
});
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 page = await context.newPage();
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 host only builds the player when the viewer presses play.
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 });
});
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);
// 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,
+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)), '../../..');
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
* gets a throwaway profile so storage from one test cannot leak into the next.
*/
export const test = base.extend({
context: async ({}, use) => {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
// The headless shell does not run MV3 service workers; the full
// 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) => {
let [worker] = context.serviceWorkers();
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
export async function writeStorage(page, 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,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.CI ? 'list' : [['list']],
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
timeout: 30_000,
expect: { timeout: 10_000 },
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 { VITEST_COVERAGE_INCLUDE } from './scripts/coverage-plan.mjs';
export default defineConfig({
test: {
@@ -20,12 +21,7 @@ export default defineConfig({
// by Vitest. Browser entry points and subprocess integration tests
// have separate E2E/integration gates and must not be reported as
// zero-coverage unit-test targets.
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'
],
include: VITEST_COVERAGE_INCLUDE,
exclude: [
'**/node_modules/**'
],