Merge pull request #37 from Shik3i/codex/test-suite-hardening

Release v3.1.5: harden teardown and popup containment
This commit is contained in:
KoalaDev
2026-08-25 01:30:37 +02:00
committed by GitHub
61 changed files with 2562 additions and 1086 deletions
+48 -3
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,12 +96,27 @@ jobs:
- name: Install root dependencies
run: npm ci
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium chromium-headless-shell
- 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
# The extension specs load dist/chrome, so the artifact has to exist.
- name: Build the extension
run: npm run build:extension
- name: Run extension E2E smoke tests
- 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
+55
View File
@@ -0,0 +1,55 @@
name: Repeated Race Tests
on:
workflow_dispatch:
schedule:
- cron: '17 3 * * *'
permissions:
contents: read
concurrency:
group: race-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
extension-races:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: package-lock.json
- 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
- name: Build the extension
run: npm run build:extension
- name: Repeat race-sensitive extension tests
run: npm run test:e2e:race
- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v7
with:
name: race-test-diagnostics
path: |
test-results/
playwright-report/
if-no-files-found: ignore
retention-days: 14
+157 -112
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,126 +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
- name: Publish verified GitHub release
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag
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 }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v20.9+
- **Node.js** v20.19+
- **Docker** (for local relay server testing)
### Quick Start
@@ -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.
---
+1 -1
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.4-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.5-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
+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.
+1 -2
View File
@@ -67,8 +67,7 @@ Useful focused checks from the repository root:
node -c extension/background.js
node -c extension/content.js
node -c extension/popup.js
node scripts/test-episode-utils.mjs
node scripts/test-title-privacy.mjs
npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs
node scripts/test-audio-settings.mjs
node scripts/test-locales.cjs
```
+50 -69
View File
@@ -1,4 +1,4 @@
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
@@ -988,15 +988,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
webJoinCoordinator.invalidate();
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
reconnectStartTime = null;
completeForceSyncBeforeTargetChange(null);
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
// Stop room-specific polling before the content script itself is removed.
// Every terminal room exit must pass through the exact target identity while
// it is still available, regardless of who initiated the exit.
clearEpisodeLobbyState();
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
@@ -1007,15 +1011,22 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId);
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
clearEpisodeLobbyState();
await clearPendingTarget();
isForceSyncInitiator = false;
forceSyncAcks.clear();
expectedAcksCount = 0;
if (forceSyncTimeout) {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
await chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
@@ -1026,17 +1037,30 @@ async function leaveRoomAfterIdleGrace(reason) {
currentTargetHasVideo: false,
roomIdleSince: null,
lastContentHeartbeatAt: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
expectedAcksCount: 0,
episodeLobby: null,
hcmDesynced: false
hcmDesynced: false,
reconnectFailed: false,
reconnectAttempts: 0,
reconnectStartTime: null
}).catch(() => {});
chatSecretGuard = '';
invalidateChatSession();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
addLog(reason, 'info');
updateBadgeStatus();
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
await endRoomSession({ notifyServer: true, reason });
}
async function connect() {
if (isConnecting) return;
isConnecting = true;
@@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) {
}
case EVENTS.ERROR:
isConnecting = false;
const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED
|| data.code === ERROR_CODES.PEER_TIMED_OUT
|| data.message === 'Room closed'
|| data.message === 'Removed from room after inactivity';
if (currentRoom && terminalRoomError) {
await endRoomSession({ reason: `Room session ended: ${data.message}` });
}
// If we get a server error before successfully joining a room,
// clear persisted credentials as well, otherwise service-worker
// restart would immediately retry the rejected room.
@@ -3340,6 +3371,14 @@ async function selectedMediaTargetMoved(tabId) {
}
return false;
}
// A disappearing ad frame can make the parent-visibility handshake
// inconclusive while still leaving one hidden mirror as the only video
// candidate. Never rebuild toward an unconfirmed nested frame: its monitor
// or a later clean probe will announce it again if it is genuinely visible.
if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) {
refreshMediaFrameMonitors(tabId).catch(() => {});
return false;
}
if (currentTargetHasVideo !== true) return true;
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|| (typeof resolved.documentId === 'string'
@@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
webJoinCoordinator.invalidate();
completeForceSyncBeforeTargetChange(null);
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
controllers = [];
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
updateBadgeStatus();
isForceSyncInitiator = false;
forceSyncAcks.clear();
expectedAcksCount = 0;
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
// Cancel any active episode lobby
clearEpisodeLobbyState();
await clearPendingTarget();
chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince: null,
lastContentHeartbeatAt: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
episodeLobby: null,
expectedAcksCount: 0,
hcmDesynced: false
});
chatSecretGuard = '';
invalidateChatSession();
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
sendResponse({ status: 'ok' });
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
+4 -1
View File
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
let markDeriveStarted;
const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
markDeriveStarted();
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
await Promise.resolve();
await deriveStarted;
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
describe('episode title matching', () => {
it.each([
['S01E01', 'S01E01'],
['S1E1', 'S01E01'],
['s01e01', 'S01E01'],
['Season 1 Episode 2', 'S01E02'],
['season 01 episode 02', 'S01E02'],
['S01 - E01', 'S01E01'],
['S01.E01', 'S01E01'],
['S01/E01', 'S01E01'],
['S01:E01', 'S01E01'],
['S01,E01', 'S01E01'],
['S01 E01', 'S01E01'],
['Folge 5', 'EP005'],
['Episode 12', 'EP012'],
['Ep. 3', 'EP003'],
['#42', 'EP042'],
['S01E001', 'S01E001']
])('extracts %s as %s', (title, expected) => {
expect(extractEpisodeId(title)).toBe(expected);
});
it.each([null, undefined, '', 123, 'Some Movie Title', 'Breaking Bad'])(
'returns null for non-episode input %j',
input => expect(extractEpisodeId(input)).toBeNull()
);
it.each([
['S01E01', 'S01E01'],
['S01E01 - Pilot', 'S01E01'],
['Folge 5', 'Episode 5'],
['Episode 12', 'Ep. 12'],
['#42', 'Folge 42'],
[null, null],
['', ''],
['Some Movie', 'Some Movie']
])('matches equivalent titles %j and %j', (left, right) => {
expect(sameEpisode(left, right)).toBe(true);
});
it.each([
['S01E01', 'S01E02'],
['S01E01', 'S02E01'],
['Folge 1', 'Folge 2'],
['Some Movie', 'Other Movie'],
['S01E01', null],
[null, 'Episode 5'],
['S01E05', 'Episode 5'],
['S01E01', 'EP001']
])('rejects different titles %j and %j', (left, right) => {
expect(sameEpisode(left, right)).toBe(false);
});
});
+196
View File
@@ -0,0 +1,196 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from './host-access.js';
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]) {
expect(normalizeTabId(invalid)).toBeNull();
}
expect(normalizeTabId('42')).toBe(42);
expect(normalizeTabId(' 42 ')).toBe(42);
});
it('describes supported origins with Firefox-compatible localhost permissions', () => {
expect(describeTabUrl('https://emby.example:8443/web/index.html')).toEqual({
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
expect(describeTabUrl('http://localhost:8096/web/', { includePort: false })).toEqual({
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
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 () => {
let containsRequest;
const deniedChrome = {
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
await expect(inspectTabHostAccess(deniedChrome, 42)).resolves.toMatchObject({
granted: false,
host: 'video.example',
originPattern: 'https://video.example/*'
});
expect(containsRequest).toEqual({ origins: ['https://video.example/*'] });
const unknownChrome = {
runtime: {},
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
permissions: { contains: (_request, callback) => callback(undefined) }
};
await expect(inspectTabHostAccess(unknownChrome, 42)).resolves.toMatchObject({ granted: null });
});
it('uses Firefox host patterns without ports', async () => {
let containsRequest;
const chromeApi = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
await expect(inspectTabHostAccess(chromeApi, 42)).resolves.toMatchObject({
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
expect(containsRequest).toEqual({ origins: ['http://localhost/*'] });
});
it('adds, removes, and requests permissions through promise and callback APIs', async () => {
let added;
expect(await addTabHostAccessRequest({
permissions: { addHostAccessRequest: async request => { added = request; } }
}, 42, 'https://video.example/*')).toBe(true);
expect(added).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
expect(await addTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
let removed;
expect(await removeTabHostAccessRequest({
permissions: { removeHostAccessRequest: async request => { removed = request; } }
}, 42, 'https://video.example/*')).toBe(true);
expect(removed).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
expect(await removeTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
const callbackChrome = {
runtime: {},
permissions: { request: (_request, callback) => callback(true) }
};
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', () => {
it('keeps activation, permission recovery, and target identity guarded', () => {
const background = fs.readFileSync(path.join(repoRoot, 'extension/background.js'), 'utf8');
const popup = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(repoRoot, 'extension/modules/tab-manager.js'), 'utf8');
expect(background).toMatch(/await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/);
expect(background).toMatch(/addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/);
expect(background).toMatch(/retryPendingTarget\(\)/);
expect(background).toMatch(/activationGeneration !== targetActivationGeneration/);
expect(background).toMatch(/pendingTargetRequestId/);
expect(background).toMatch(/addedOrigins\.includes\(pending\.originPattern\)/);
expect(background).toMatch(/isCurrentTargetIdentity\(tabId, targetGeneration\)/);
expect(background).toMatch(/message\.expectedTabId/);
expect(background).toMatch(/completeForceSyncBeforeTargetChange\(selectedTabId\)/);
expect(background).toMatch(/FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/);
expect(background).toMatch(/removeTabHostAccessRequest\([\s\S]*pendingTabId/);
const activationBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
expect(activationBody.indexOf('await injectContentScript')).toBeLessThan(
activationBody.indexOf('currentTabId = selectedTabId')
);
expect(popup).toMatch(/response\?\.status === 'host_permission_required'/);
expect(popup).toMatch(/requestOriginPermission\(chrome, requestedOriginPattern\)/);
expect(popup).toMatch(/expectedCurrentTabId: tabId/);
expect(popup).toMatch(/expectedTabId: tabId/);
expect(tabManager).not.toMatch(/injectContentScript/);
expect((background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length).toBe(1);
expect(popupHtml).toMatch(/id="siteAccessNotice"/);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "3.1.4",
"version": "3.1.5",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
+1
View File
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
documentId,
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
hasVideo: !!selected?.result?.bestVideo,
visibilityConfirmed: selected?.result?.parentFrameVisible === true,
scriptTarget: documentId
? { tabId, documentIds: [documentId] }
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
+3
View File
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
visibilityConfirmed: true,
// Reported back so the caller can address these frames directly when
// a later all-frames sweep is rejected wholesale.
discoveredFrameIds: [0, 8],
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
visibilityConfirmed: false,
discoveredFrameIds: [0, 6],
scriptTarget: { tabId: 42 }
});
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
visibilityConfirmed: false,
discoveredFrameIds: [0],
scriptTarget: { tabId: 42 }
});
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.html'), 'utf8');
describe('popup layout containment', () => {
it('prevents dynamic descendants from changing the 360px popup width', () => {
expect(popupSource).toMatch(/html\s*\{[^}]*width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*min-width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*max-width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*overflow-x:\s*hidden;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*width:\s*360px;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*max-width:\s*360px;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*contain:\s*inline-size;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*overflow-x:\s*hidden;/s);
});
});
+25 -11
View File
@@ -180,11 +180,25 @@
color-scheme: light;
}
/* Defensive popup boundary: descendants may be populated dynamically
after Chrome has measured the action popup. Keep their intrinsic
width from resizing the popup window while preserving normal vertical
layout and scrolling inside the established 360px surface. */
html {
width: 360px;
min-width: 360px;
max-width: 360px;
overflow-x: hidden;
}
body {
width: 360px;
max-width: 360px;
margin: 0;
padding: 18px;
box-sizing: border-box;
contain: inline-size;
overflow-x: hidden;
background: var(--bg);
color: var(--text);
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -1413,16 +1427,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 +1524,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 +1590,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 +1827,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 +1859,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) {
+33
View File
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('routes every terminal room exit through the full target unhook', () => {
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
.toBeLessThan(teardownSource.indexOf('currentTabId = null'));
expect(teardownSource).toContain('await clearPendingTarget()');
expect(teardownSource).toContain('forceDisconnect()');
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
expect(backgroundSource).toContain("data.message === 'Room closed'");
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
});
it('does not promote a nested media target without confirmed parent visibility', () => {
expect(backgroundSource).toContain(
'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
);
});
it('removes monitors injected by a superseded cross-tab activation', () => {
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
TITLE_PRIVACY_MODES,
applyTitlePrivacyToPayload,
normalizeSendTabTitle,
normalizeTabTitle,
normalizeTitlePrivacyMode,
sanitizeSharedTitle,
sanitizeTabTitle
} from './title-privacy.js';
describe('title privacy', () => {
it('normalizes settings and tab notification prefixes', () => {
expect(normalizeTitlePrivacyMode(undefined)).toBe(TITLE_PRIVACY_MODES.FULL);
expect(normalizeTitlePrivacyMode('unknown')).toBe(TITLE_PRIVACY_MODES.FULL);
expect(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN)).toBe(TITLE_PRIVACY_MODES.HIDDEN);
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL)).toBe(true);
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE)).toBe(false);
expect(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN)).toBe(true);
expect(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL)).toBe(false);
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();
expect(sanitizeSharedTitle('Show Name - S01E04', 'hidden')).toBeNull();
});
it('rewrites only present media keys without mutating the input', () => {
const input = {
tabTitle: 'Private Tab',
mediaTitle: 'Private Media',
expectedTitle: 'S01E04',
title: 'S01E04',
currentTime: 42
};
expect(applyTitlePrivacyToPayload(input, 'hidden')).toEqual({
tabTitle: 'Private Tab',
mediaTitle: null,
expectedTitle: null,
title: null,
currentTime: 42
});
expect(input.mediaTitle).toBe('Private Media');
expect(applyTitlePrivacyToPayload({ tabTitle: 'Private Tab', status: 'heartbeat' }, 'episode')).toEqual({
tabTitle: 'Private Tab',
status: 'heartbeat'
});
});
});
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"devDependencies": {
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
@@ -23,7 +23,7 @@
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.9.0"
"node": ">=20.19.0"
}
},
"node_modules/@babel/code-frame": {
+8 -3
View File
@@ -1,21 +1,26 @@
{
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"description": "KoalaSync Build Scripts",
"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",
"test:e2e:install": "playwright install chromium chromium-headless-shell",
"test:e2e:detection": "playwright test --config tests/e2e/playwright.config.mjs --project=detection-chromium --project=detection-firefox --project=detection-webkit",
"test:e2e:extension": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium",
"test:e2e:install": "playwright install chromium chromium-headless-shell firefox webkit",
"test:e2e:race": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium --grep @race --repeat-each=20",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run",
"verify": "node scripts/verify-release.mjs"
},
+36 -8
View File
@@ -9,12 +9,16 @@ npm run build:extension
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`.
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
- `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
@@ -59,9 +63,9 @@ npm run verify
It currently runs:
- Vitest unit tests.
- Server ops, route, WebSocket, and rate-limiter checks.
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
- Vitest unit tests with coverage thresholds for importable source modules.
- Server route and WebSocket integration checks.
- Episode parser, title privacy, host access, blacklist, names, rate limiting, audio settings, popup cooldown, and content-video-finder checks.
- JavaScript syntax checks for server and extension entry points.
- Extension and website locale coverage checks.
- ESLint.
@@ -72,19 +76,43 @@ It currently runs:
| Script | Purpose |
|:---|:---|
| `test-server-ops.mjs` | Health payload and admin metrics helpers |
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
| `test-episode-utils.mjs` | Episode-title extraction and comparison |
| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
| `test-names.mjs` | Generated username format and coverage |
| `test-content-video-finder.cjs` | Content-script video selection helpers |
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
| `test-website-locales.mjs` | Website locale coverage |
## Coverage Boundary
`vitest.config.mjs` covers importable modules executed by Vitest and enforces
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
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 vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync
```
The verifier requires the exact three release assets, validates SHA-256 hashes,
annotated-tag ancestry, Chrome/Firefox manifest versions and runtime injection,
archive parity, unsafe/development-only paths, and GitHub attestations. For a
local archive-only diagnosis, pass `--asset-dir PATH`; this deliberately skips
GitHub inventory and attestation checks.
## Do Not Break
- Keep scripts runnable from the repository root.
+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');
});
});
+123
View File
@@ -0,0 +1,123 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
export const RELEASE_ASSET_NAMES = Object.freeze([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS'
]);
const REQUIRED_ARCHIVE_ENTRIES = Object.freeze([
'manifest.json',
'background.js',
'content.js',
'popup.html',
'shared/constants.js'
]);
export function versionFromTag(tag) {
const match = /^v(\d+\.\d+\.\d+)$/u.exec(tag || '');
if (!match) throw new Error(`Release tag must match vMAJOR.MINOR.PATCH: ${tag || '<empty>'}`);
return match[1];
}
export function parseChecksumFile(text) {
const checksums = new Map();
for (const [index, rawLine] of String(text).split(/\r?\n/u).entries()) {
if (!rawLine.trim()) continue;
const match = /^([a-fA-F0-9]{64}) ([^/\\]+)$/u.exec(rawLine);
if (!match) throw new Error(`Invalid SHA256SUMS line ${index + 1}: ${rawLine}`);
const [, digest, filename] = match;
if (checksums.has(filename)) throw new Error(`Duplicate checksum entry: ${filename}`);
checksums.set(filename, digest.toLowerCase());
}
return checksums;
}
export async function sha256File(filePath) {
const hash = crypto.createHash('sha256');
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
return hash.digest('hex');
}
export function validateReleaseAssetNames(assetNames) {
const actual = [...new Set(assetNames)].sort();
const expected = [...RELEASE_ASSET_NAMES].sort();
if (actual.length !== assetNames.length) throw new Error('Release contains duplicate asset names');
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Release assets differ: expected ${expected.join(', ')}, got ${actual.join(', ')}`);
}
}
export function validateArchiveEntries(browserName, archiveEntries) {
if (!Array.isArray(archiveEntries)) throw new Error(`${browserName} archive entries must be an array`);
const seen = new Set();
const files = new Set();
for (const entry of archiveEntries) {
if (typeof entry !== 'string' || !entry) throw new Error(`${browserName} archive contains an invalid entry`);
if (seen.has(entry)) throw new Error(`${browserName} archive contains duplicate entry: ${entry}`);
seen.add(entry);
if (entry.startsWith('/')
|| /^[A-Za-z]:[\\/]/u.test(entry)
|| entry.includes('\\')
|| entry.includes('\0')
|| entry.split('/').includes('..')) {
throw new Error(`${browserName} archive contains unsafe path: ${entry}`);
}
if (entry.endsWith('/')) continue;
files.add(entry);
if (/\.test\.[cm]?js$/u.test(entry)
|| entry === 'manifest.base.json'
|| entry === '.DS_Store'
|| entry.endsWith('/.DS_Store')) {
throw new Error(`${browserName} archive contains development-only file: ${entry}`);
}
}
for (const required of REQUIRED_ARCHIVE_ENTRIES) {
if (!seen.has(required)) throw new Error(`${browserName} archive is missing ${required}`);
}
return [...files].sort();
}
export function validateManifest(browserName, manifest, expectedVersion) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error(`${browserName} manifest must be a JSON object`);
}
if (manifest.version !== expectedVersion) {
throw new Error(`${browserName} manifest version ${manifest.version || '<missing>'} does not match ${expectedVersion}`);
}
if (manifest.manifest_version !== 3) {
throw new Error(`${browserName} manifest must use Manifest V3`);
}
if (browserName === 'chrome') {
if (manifest.background?.service_worker !== 'background.js') {
throw new Error('Chrome manifest must use background.js as its service worker');
}
if (manifest.background?.type !== 'module') throw new Error('Chrome background must be an ES module');
if (manifest.browser_specific_settings?.gecko) {
throw new Error('Chrome manifest must not contain Firefox gecko settings');
}
} else if (browserName === 'firefox') {
if (!Array.isArray(manifest.background?.scripts)
|| manifest.background.scripts.length !== 1
|| manifest.background.scripts[0] !== 'background.js') {
throw new Error('Firefox manifest must use background.js as its background script');
}
if (manifest.background?.type !== 'module') throw new Error('Firefox background must be an ES module');
if (manifest.browser_specific_settings?.gecko?.id !== 'koalasync@koalastuff.net') {
throw new Error('Firefox manifest is missing the expected extension ID');
}
} else {
throw new Error(`Unsupported browser archive: ${browserName}`);
}
}
export function validateArchiveParity(chromeEntries, firefoxEntries) {
const chrome = [...chromeEntries].sort();
const firefox = [...firefoxEntries].sort();
if (JSON.stringify(chrome) !== JSON.stringify(firefox)) {
const chromeOnly = chrome.filter(entry => !firefox.includes(entry));
const firefoxOnly = firefox.filter(entry => !chrome.includes(entry));
throw new Error(`Archive contents differ; Chrome only: ${chromeOnly.join(', ') || '<none>'}; Firefox only: ${firefoxOnly.join(', ') || '<none>'}`);
}
}
+153
View File
@@ -0,0 +1,153 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
parseChecksumFile,
sha256File,
validateArchiveEntries,
validateArchiveParity,
validateManifest,
validateReleaseAssetNames,
versionFromTag
} from './release-artifact-checks.mjs';
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe('published release artifact checks', () => {
it('accepts semantic release tags and rejects ambiguous versions', () => {
expect(versionFromTag('v3.1.4')).toBe('3.1.4');
for (const invalid of ['3.1.4', 'v3.1', 'v3.1.4-beta', '', null]) {
expect(() => versionFromTag(invalid)).toThrow('vMAJOR.MINOR.PATCH');
}
});
it('parses strict sha256sum output and rejects duplicate or unsafe names', () => {
const digest = 'a'.repeat(64);
expect(parseChecksumFile(`${digest} koalasync-chrome.zip\n`).get('koalasync-chrome.zip')).toBe(digest);
expect(() => parseChecksumFile(`${digest} *koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
expect(() => parseChecksumFile(`${digest} ../koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
expect(() => parseChecksumFile(`${digest} chrome.zip\n${digest} chrome.zip`)).toThrow('Duplicate checksum');
});
it('computes file digests without platform-specific checksum commands', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-checksum-test-'));
temporaryDirectories.push(directory);
const filePath = path.join(directory, 'fixture.txt');
fs.writeFileSync(filePath, 'koalasync\n');
await expect(sha256File(filePath)).resolves.toBe('2ee7e74af89fb4f42d4fa1bcf93c588bf4460a62c8def5c254bba7b5ae6cd544');
});
it('requires the exact public release asset inventory', () => {
expect(() => validateReleaseAssetNames([
'koalasync-firefox.zip',
'SHA256SUMS',
'koalasync-chrome.zip'
])).not.toThrow();
expect(() => validateReleaseAssetNames(['koalasync-chrome.zip'])).toThrow('Release assets differ');
expect(() => validateReleaseAssetNames([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS',
'debug.log'
])).toThrow('Release assets differ');
expect(() => validateReleaseAssetNames([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS',
'SHA256SUMS'
])).toThrow('duplicate asset names');
});
it('rejects missing, duplicate, traversal, and development-only archive entries', () => {
const valid = ['manifest.json', 'background.js', 'content.js', 'popup.html', 'shared/constants.js'];
expect(validateArchiveEntries('chrome', valid)).toEqual([...valid].sort());
expect(validateArchiveEntries('chrome', [...valid, 'assets/'])).toEqual([...valid].sort());
expect(() => validateArchiveEntries('chrome', null)).toThrow('entries must be an array');
expect(() => validateArchiveEntries('chrome', [...valid, ''])).toThrow('invalid entry');
expect(() => validateArchiveEntries('chrome', valid.slice(1))).toThrow('missing manifest.json');
expect(() => validateArchiveEntries('chrome', [...valid, 'content.js'])).toThrow('duplicate entry');
expect(() => validateArchiveEntries('chrome', [...valid, '../secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, '../'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, 'C:/secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, '..\\secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, 'content.test.mjs'])).toThrow('development-only');
expect(() => validateArchiveEntries('chrome', [...valid, 'assets/.DS_Store'])).toThrow('development-only');
});
it('validates browser-specific manifests and version alignment', () => {
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).not.toThrow();
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'module' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).not.toThrow();
expect(() => validateManifest('chrome', {
version: '3.1.3',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).toThrow('does not match 3.1.4');
expect(() => validateManifest('chrome', null, '3.1.4')).toThrow('must be a JSON object');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 2,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).toThrow('Manifest V3');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'wrong.js', type: 'module' }
}, '3.1.4')).toThrow('service worker');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'classic' }
}, '3.1.4')).toThrow('ES module');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' },
browser_specific_settings: { gecko: { id: 'unexpected@example.test' } }
}, '3.1.4')).toThrow('must not contain Firefox');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['wrong.js'], type: 'module' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).toThrow('background script');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'classic' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).toThrow('ES module');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'module' }
}, '3.1.4')).toThrow('expected extension ID');
expect(() => validateManifest('safari', {
version: '3.1.4',
manifest_version: 3
}, '3.1.4')).toThrow('Unsupported browser');
});
it('requires Chrome and Firefox to ship the same file set', () => {
expect(() => validateArchiveParity(['a', 'b'], ['b', 'a'])).not.toThrow();
expect(() => validateArchiveParity(['a', 'chrome-only'], ['a', 'firefox-only'])).toThrow(
'Chrome only: chrome-only; Firefox only: firefox-only'
);
});
});
+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');
});
});
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BLACKLIST_DOMAINS,
BLACKLIST_OVERRIDES_STORAGE_KEY,
BLACKLIST_SOURCE_DEFAULT,
BLACKLIST_SOURCE_USER,
CUSTOM_BLACKLIST_STORAGE_KEY,
createEmptyBlacklistOverrides,
deriveBlacklistOverrides,
getBlacklistEntries,
getEffectiveBlacklistDomains,
isUrlBlacklisted,
normalizeBlacklistDomain,
normalizeBlacklistOverrides,
parseBlacklistDomains
} from '../shared/blacklist.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
assert.equal(CUSTOM_BLACKLIST_STORAGE_KEY, 'customBlacklistDomains');
assert.equal(normalizeBlacklistDomain(' Example.COM. '), 'example.com');
assert.equal(normalizeBlacklistDomain('https://Video.Example.com/watch/123'), 'video.example.com');
assert.equal(normalizeBlacklistDomain('*.example.com'), null, 'wildcards are rejected');
assert.equal(normalizeBlacklistDomain('not a domain'), null, 'spaces are rejected');
const parsed = parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n');
assert.deepEqual(parsed.domains, ['example.com', 'sub.example.com'], 'domains are normalized and deduplicated');
assert.deepEqual(parsed.invalid, []);
const invalid = parseBlacklistDomains('example.com\nnot a domain');
assert.deepEqual(invalid.invalid, ['not a domain'], 'invalid entries are reported without partial silent saves');
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'missing local setting uses shipped defaults');
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'an explicitly empty local list stays empty');
assert.equal(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com']), true, 'subdomains match a parent domain');
assert.equal(isUrlBlacklisted('https://notgoogle.com/', ['google.com']), false, 'lookalike domains do not match');
assert.equal(isUrlBlacklisted('not a url', ['example.com']), false, 'invalid URLs are ignored');
// --- Delta storage: shipped defaults keep flowing in after the user edits ---
assert.equal(BLACKLIST_OVERRIDES_STORAGE_KEY, 'blacklistOverrides');
assert.deepEqual(createEmptyBlacklistOverrides(), { removedDefaults: [], addedDomains: [] });
// A user who removes two defaults and adds one of their own.
const edited = BLACKLIST_DOMAINS
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
.concat(['videos.example']);
const overrides = deriveBlacklistOverrides(edited);
assert.deepEqual(overrides.removedDefaults, ['reddit.com', 'imgur.com'], 'only the removed defaults are stored');
assert.deepEqual(overrides.addedDomains, ['videos.example'], 'only the added domains are stored');
const effective = getEffectiveBlacklistDomains(overrides);
const effectiveDomains = new Set(effective);
assert.equal(effectiveDomains.has('reddit.com'), false, 'a removed default stays removed');
assert.equal(effectiveDomains.has('videos.example'), true, 'an added domain stays added');
// The property that makes newly shipped defaults reach existing users: every
// shipped domain the user did not explicitly remove is part of the result, so a
// default added in a later version cannot be missing from a stored delta.
const removedSet = new Set(overrides.removedDefaults);
for (const domain of BLACKLIST_DOMAINS) {
assert.equal(
effectiveDomains.has(domain) || removedSet.has(domain),
true,
`shipped default ${domain} must be present unless explicitly removed`
);
}
// Legacy full-list snapshots migrate to the delta form.
assert.deepEqual(
deriveBlacklistOverrides(edited),
normalizeBlacklistOverrides(overrides),
'a legacy snapshot produces the same delta'
);
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'no stored delta uses shipped defaults');
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'a legacy empty snapshot still means no filtering');
// Re-adding a removed default clears the removal instead of stacking state.
const readded = deriveBlacklistOverrides(effective.concat(['reddit.com']), overrides);
const readdedRemovedDefaults = new Set(readded.removedDefaults);
assert.equal(readdedRemovedDefaults.has('reddit.com'), false, 're-adding a default clears its removal');
// A domain the user added explicitly stays tagged as theirs even once the same
// domain ships as a default, so dropping the default does not drop their entry.
const stillUser = deriveBlacklistOverrides(['google.com'], { removedDefaults: [], addedDomains: ['google.com'] });
assert.deepEqual(stillUser.addedDomains, ['google.com'], 'an explicit addition survives becoming a default');
// Contradictory stored state resolves in favour of the addition.
assert.deepEqual(
normalizeBlacklistOverrides({ removedDefaults: ['example.com'], addedDomains: ['example.com'] }),
{ removedDefaults: [], addedDomains: ['example.com'] },
'a domain cannot be removed and added at once'
);
assert.deepEqual(normalizeBlacklistOverrides('nonsense'), createEmptyBlacklistOverrides(), 'garbage storage falls back to defaults');
// Entries are tagged so the editor can show what came from where.
const entries = getBlacklistEntries(overrides);
assert.equal(entries.find(e => e.domain === 'videos.example').source, BLACKLIST_SOURCE_USER);
assert.equal(entries.find(e => e.domain === 'google.com').source, BLACKLIST_SOURCE_DEFAULT);
// Comment lines are editor notes, not domains, and never count as invalid.
const withComments = parseBlacklistDomains('# your entries\nvideos.example\n\n#shipped defaults\ngoogle.com');
assert.deepEqual(withComments.domains, ['videos.example', 'google.com'], 'comment lines are skipped');
assert.deepEqual(withComments.invalid, [], 'comment lines are not reported as invalid');
// Round trip through the grouped editor body: rendering with comment headers
// and saving it again must not change the stored delta.
const rendered = [
'# Your entries',
...entries.filter(e => e.source === BLACKLIST_SOURCE_USER).map(e => e.domain),
'',
'# Shipped defaults',
...entries.filter(e => e.source === BLACKLIST_SOURCE_DEFAULT).map(e => e.domain)
].join('\n');
const roundTripped = parseBlacklistDomains(rendered);
assert.deepEqual(roundTripped.invalid, [], 'the rendered editor body contains no invalid entries');
assert.deepEqual(
deriveBlacklistOverrides(roundTripped.domains, overrides),
normalizeBlacklistOverrides(overrides),
'render then save leaves the delta unchanged'
);
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/, 'the delta is saved locally');
assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/, 'the list is never synced');
assert.match(popupSource, /chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/, 'the legacy snapshot is cleaned up after migration');
assert.match(popupSource, /isUrlBlacklisted\(tab\.url, blacklistDomains\)/, 'tab filtering uses the effective custom list');
// A broad parent domain must not hide a host with a dedicated player path,
// but an exact user entry for that host still filters it.
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', BLACKLIST_DOMAINS), false);
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', ['drive.google.com']), true);
assert.equal(isUrlBlacklisted('https://docs.google.com/document/d/x', BLACKLIST_DOMAINS), true);
assert.equal(isUrlBlacklisted('https://mail.google.com/mail/u/0', BLACKLIST_DOMAINS), true);
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
assert.match(popupHtml, /id="blacklistDomains"/, 'settings UI contains the editable domain list');
assert.match(popupHtml, /id="blacklistReset"/, 'settings UI contains a defaults reset');
console.log('blacklist settings tests passed');
-76
View File
@@ -1,76 +0,0 @@
import assert from 'node:assert/strict';
import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js';
// --- extractEpisodeId ---
// Standard SxxExx patterns
assert.equal(extractEpisodeId('S01E01'), 'S01E01');
assert.equal(extractEpisodeId('S1E1'), 'S01E01');
assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive');
assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02');
assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02');
// Separators: dash, dot, slash, colon, space, comma
assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator');
assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator');
assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)');
assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator');
assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator');
assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator');
// German / multi-language
assert.equal(extractEpisodeId('Folge 5'), 'EP005');
assert.equal(extractEpisodeId('Episode 12'), 'EP012');
assert.equal(extractEpisodeId('Ep. 3'), 'EP003');
assert.equal(extractEpisodeId('#42'), 'EP042');
// Edge cases
assert.equal(extractEpisodeId(null), null);
assert.equal(extractEpisodeId(undefined), null);
assert.equal(extractEpisodeId(''), null);
assert.equal(extractEpisodeId(123), null);
assert.equal(extractEpisodeId('Some Movie Title'), null);
assert.equal(extractEpisodeId('Breaking Bad'), null);
// Leading zeros preserved
assert.equal(extractEpisodeId('S01E001'), 'S01E001');
// --- sameEpisode ---
// Identical episodes
assert.equal(sameEpisode('S01E01', 'S01E01'), true);
assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English');
// Different episodes
assert.equal(sameEpisode('S01E01', 'S01E02'), false);
assert.equal(sameEpisode('Folge 1', 'Folge 2'), false);
assert.equal(sameEpisode('S01E01', 'S02E01'), false);
// Both unknown → assume same (backward compat)
assert.equal(sameEpisode(null, null), true);
assert.equal(sameEpisode(undefined, undefined), true);
assert.equal(sameEpisode('', ''), true);
assert.equal(sameEpisode('Some Movie', 'Some Movie'), true);
assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ');
// One unknown, one known → different
assert.equal(sameEpisode('S01E01', null), false);
assert.equal(sameEpisode(null, 'Episode 5'), false);
assert.equal(sameEpisode(undefined, 'S01E01'), false);
// Mixed formats — only match when the same episode
assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode');
assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X');
assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X');
// Different format IDs → different (season-tagged vs seasonless)
assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs');
assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs');
// parseable but truly different
assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes');
assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons');
console.log('episode-utils tests passed');
-180
View File
@@ -1,180 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from '../extension/host-access.js';
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
assert.equal(normalizeTabId(null), null);
assert.equal(normalizeTabId(undefined), null);
assert.equal(normalizeTabId(''), null);
assert.equal(normalizeTabId(0), null);
assert.equal(normalizeTabId('42'), 42);
assert.equal(normalizeTabId(true), null);
assert.equal(normalizeTabId([42]), null);
assert.equal(normalizeTabId('42.5'), null);
assert.equal(normalizeTabId(' 42 '), 42);
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost:8096/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
assert.equal(describeTabUrl('chrome://extensions/'), null);
assert.equal(describeTabUrl('not a url'), null);
let containsRequest = null;
const deniedChrome = {
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
const access = await inspectTabHostAccess(deniedChrome, 42);
assert.equal(access.granted, false);
assert.equal(access.host, 'video.example');
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
let firefoxContainsRequest = null;
const firefoxChrome = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
firefoxContainsRequest = request;
return false;
}
}
};
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
assert.equal(firefoxAccess.host, 'localhost:8096');
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
const unknownPermissionChrome = {
runtime: {},
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: (_request, callback) => { callback(undefined); }
}
};
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
let requestedTabId = null;
const requestChrome = {
permissions: {
addHostAccessRequest: async request => { requestedTabId = request; }
}
};
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
let removedTabId = null;
const removeRequestChrome = {
permissions: {
removeHostAccessRequest: async request => { removedTabId = request; }
}
};
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
const callbackPermissionChrome = {
runtime: {},
permissions: {
request: (_request, callback) => { callback(true); }
}
};
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
assert.match(background, /await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/,
'SET_TARGET_TAB must await successful activation before acknowledging it');
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
'failed injection must register Chrome host-access request');
assert.match(background, /retryPendingTarget\(\)/,
'pending target must resume after the user grants access');
assert.match(background, /activationGeneration !== targetActivationGeneration/,
'stale concurrent tab activations must not overwrite the newest selection');
assert.match(background, /pendingTargetRequestId/,
'pending access recovery must use an identity token');
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
'unrelated permission grants must not activate a pending target');
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
'stale content-routing retries must not reactivate an old target');
assert.match(background, /message\.expectedTabId/,
'popup playback events must be rejected after their target changes');
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
'a target switch must finish an in-flight force sync on the old target');
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
'stale content scripts must not acknowledge force sync for a new target');
const activateTargetBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
assert.ok(
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
'a tab must not become current until its content script injection succeeds'
);
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
'clearing a pending target must also clear Chrome toolbar access requests');
assert.match(popup, /response\?\.status === 'host_permission_required'/,
'popup must render the structured host-access failure');
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
'retry button must request withheld host access directly');
assert.match(popup, /expectedCurrentTabId: tabId/,
'manual reinjection must be tied to the selected target identity');
assert.match(popup, /expectedTabId: tabId/,
'force sync must be tied to the tab whose time was sampled');
assert.doesNotMatch(tabManager, /injectContentScript/,
'tab reload recovery must use the guarded background activation path');
assert.equal(
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
1,
'target-tab closure must have exactly one state owner'
);
assert.match(popupHtml, /id="siteAccessNotice"/,
'popup must contain a persistent site-access notice');
console.log('host access recovery tests passed');
-56
View File
@@ -1,56 +0,0 @@
import assert from 'node:assert/strict';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
// --- getAvatarForName (deterministic) ---
// Exact matches
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
// Case insensitive
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
// Longest match wins (caterpillar > cat)
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
// Emoji with ZWJ sequences (multi-codepoint)
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
// Human-like characters
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
// Fallback
assert.equal(getAvatarForName(''), '👤', 'empty string');
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
assert.equal(getAvatarForName(null), '👤', 'null');
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
// --- generateUsername (format check) ---
for (let i = 0; i < 10; i++) {
const name = generateUsername();
// Format: AdjectiveNoun (e.g. "HappyKoala")
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
// Adjective from list
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
assert.ok(adj, `adjective from list: ${name}`);
// Noun from list
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
assert.ok(noun, `noun from list: ${name}`);
}
// Every noun has an emoji (no broken usernames)
for (const noun of USERNAME_NOUNS) {
const avatar = getAvatarForName(noun);
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
}
console.log('names tests passed');
-131
View File
@@ -1,131 +0,0 @@
import assert from 'node:assert/strict';
import {
checkConnectionRate,
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
checkAuthRate,
recordAuthFailure,
clearRateLimitMaps,
connectionCounts,
failedAuthAttempts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
leaveRoomCounts,
rateLimitDenied,
startRateLimitCleanup,
stopRateLimitCleanup,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT,
LEAVE_ROOM_RATE_LIMIT
} from '../server/rate-limiter.js';
// Helper: mock io for cleanup
const mockIo = { sockets: { sockets: new Map() } };
// Reset state before each test group
function reset() {
clearRateLimitMaps();
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
stopRateLimitCleanup();
}
// --- checkConnectionRate ---
reset();
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
reset();
assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
// --- checkEventRate ---
reset();
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.events, 1);
reset();
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
// --- checkLeaveRoomRate ---
reset();
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.leaveRoom, 1);
reset();
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room socket independent');
// --- checkHealthRate ---
reset();
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4');
assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked');
assert.equal(rateLimitDenied.health, 1);
// --- checkAdminMetricsAuthRate ---
reset();
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed');
for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8');
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked');
assert.equal(rateLimitDenied.adminMetricsAuth, 1);
// --- checkAuthRate ---
reset();
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed');
for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a');
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked');
assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked');
// --- recordAuthFailure ---
reset();
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.size, 1, 'failure recorded');
const record = failedAuthAttempts.get('10.0.0.2:room-x');
assert.equal(record.count, 1, 'count incremented');
assert.ok(record.lastAttempt <= Date.now(), 'timestamp set');
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat');
// --- clearRateLimitMaps ---
reset();
connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 });
eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
roomListCooldowns.set('sock2', Date.now());
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
clearRateLimitMaps();
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts cleared');
// --- startRateLimitCleanup / stopRateLimitCleanup ---
reset();
startRateLimitCleanup(mockIo);
startRateLimitCleanup(mockIo); // double-start guard
stopRateLimitCleanup();
assert.ok(true, 'cleanup start/stop does not throw');
// --- rateLimitDenied reset ---
reset();
rateLimitDenied.connections = 5;
rateLimitDenied.leaveRoom = 5;
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
console.log('rate-limiter tests passed');
-89
View File
@@ -1,89 +0,0 @@
import assert from 'node:assert/strict';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from '../server/ops.js';
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
assert.equal(
isAdminMetricsTokenStrong('a'.repeat(32)),
true,
'admin token with at least 32 characters should be considered strong'
);
const cooldowns = new Map();
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
const cache = new Map();
let buildCalls = 0;
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
const basicHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: false,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
});
assert.deepEqual(
Object.keys(basicHealth).sort(),
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
'basic health should not expose extended metrics'
);
const adminHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: true,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
rateLimitDenied: { leaveRoom: 8 }
});
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
console.log('server ops tests passed');
+40
View File
@@ -67,6 +67,25 @@ try {
close();
resetConnectionRate();
// --- Stale peer reaper: terminal timeout + clean rejoin ---
const staleClient = await c();
const staleRoomId = 'stale-'+Date.now();
await j(staleClient, staleRoomId, 'stale-peer');
staleClient._m.length = 0;
const staleRoom = mod.rooms.get(staleRoomId);
staleRoom.peerData.values().next().value.lastSeen = 1;
mod.cleanupInactiveRooms(Date.now());
const [staleEvent, staleData] = await a(staleClient);
assert.equal(staleEvent, 'error');
assert.equal(staleData.code, 'peer_timed_out');
assert.equal(staleData.message, 'Removed from room after inactivity');
assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
staleClient._m.length = 0;
await j(staleClient, staleRoomId, 'stale-peer');
assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
close();
resetConnectionRate();
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
const capClient = await c();
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
@@ -80,6 +99,27 @@ try {
close();
resetConnectionRate();
// --- Terminal room timeout: coded error + complete membership cleanup ---
const timeoutClient = await c();
const timeoutRoomId = 'timeout-'+Date.now();
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
timeoutClient._m.length = 0;
mod.rooms.get(timeoutRoomId).lastActivity = 0;
mod.cleanupInactiveRooms(Date.now());
const [timeoutEvent, timeoutData] = await a(timeoutClient);
assert.equal(timeoutEvent, 'error');
assert.equal(timeoutData.code, 'room_closed');
assert.equal(timeoutData.message, 'Room closed');
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
timeoutClient._m.length = 0;
// The same connected socket must be able to join that room again. This
// proves timeout cleanup removed its stale socketToRoom membership.
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
close();
resetConnectionRate();
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
-87
View File
@@ -1,87 +0,0 @@
import assert from 'node:assert/strict';
import {
TITLE_PRIVACY_MODES,
applyTitlePrivacyToPayload,
normalizeSendTabTitle,
normalizeTabTitle,
normalizeTitlePrivacyMode,
sanitizeSharedTitle,
sanitizeTabTitle
} from '../extension/title-privacy.js';
assert.equal(normalizeTitlePrivacyMode(undefined), TITLE_PRIVACY_MODES.FULL);
assert.equal(normalizeTitlePrivacyMode('unknown'), TITLE_PRIVACY_MODES.FULL);
assert.equal(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN), TITLE_PRIVACY_MODES.HIDDEN);
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle(' '), null);
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('(12) Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('Private Tab', false), null);
assert.equal(sanitizeTabTitle('', true), null);
assert.equal(sanitizeSharedTitle('Example Movie', 'full'), 'Example Movie');
assert.equal(sanitizeSharedTitle('', 'full'), null);
assert.equal(sanitizeSharedTitle(null, 'full'), null);
assert.equal(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode'), 'S01E04');
assert.equal(sanitizeSharedTitle('Folge 7 - Private Server', 'episode'), 'EP007');
assert.equal(sanitizeSharedTitle('Example Movie', 'episode'), null);
assert.equal(sanitizeSharedTitle('Show Name - S01E04', 'hidden'), null);
assert.equal(sanitizeSharedTitle('Private Tab Title', 'hidden'), null);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Jellyfin - S01E04',
mediaTitle: 'Show Name - S01E04',
currentTime: 42
}, 'episode'),
{
tabTitle: 'Private Jellyfin - S01E04',
mediaTitle: 'S01E04',
currentTime: 42
},
'media privacy must not rewrite tabTitle'
);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Jellyfin - S01E04',
status: 'heartbeat'
}, 'episode'),
{
tabTitle: 'Private Jellyfin - S01E04',
status: 'heartbeat'
},
'media privacy must not rewrite tabTitle or add absent media keys'
);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Tab',
mediaTitle: 'Private Media',
expectedTitle: 'S01E04',
title: 'S01E04'
}, 'hidden'),
{
tabTitle: 'Private Tab',
mediaTitle: null,
expectedTitle: null,
title: null
},
'hidden media privacy must not clear tabTitle'
);
console.log('title-privacy tests passed');
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
parseChecksumFile,
RELEASE_ASSET_NAMES,
sha256File,
validateArchiveEntries,
validateArchiveParity,
validateManifest,
validateReleaseAssetNames,
versionFromTag
} from './release-artifact-checks.mjs';
function parseArgs(argv) {
const options = { tag: '', repo: '', assetDir: '', skipAttestation: false };
const positional = [];
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === '--repo' || argument === '--asset-dir') {
const value = argv[++index];
if (!value) throw new Error(`${argument} requires a value`);
if (argument === '--repo') options.repo = value;
else options.assetDir = path.resolve(value);
} else if (argument === '--skip-attestation') {
options.skipAttestation = true;
} else if (argument.startsWith('-')) {
throw new Error(`Unknown option: ${argument}`);
} else {
positional.push(argument);
}
}
if (positional.length !== 1) {
throw new Error('Usage: node scripts/verify-published-release.mjs <tag> [--repo OWNER/REPO] [--asset-dir PATH] [--skip-attestation]');
}
options.tag = positional[0];
return options;
}
function run(command, args, { capture = true } = {}) {
return execFileSync(command, args, {
cwd: process.cwd(),
encoding: capture ? 'utf8' : undefined,
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
});
}
function readArchiveText(archivePath, entry) {
return run('unzip', ['-p', archivePath, entry]);
}
function listArchiveEntries(archivePath) {
return run('unzip', ['-Z1', archivePath]).split(/\r?\n/u).filter(Boolean);
}
function assertRuntimeBuild(browserName, archivePath, version) {
const constants = readArchiveText(archivePath, 'shared/constants.js');
const background = readArchiveText(archivePath, 'background.js');
const content = readArchiveText(archivePath, 'content.js');
const popup = readArchiveText(archivePath, 'popup.html');
if (!constants.includes(`export const APP_VERSION = "${version}";`)) {
throw new Error(`${browserName} shared/constants.js does not contain APP_VERSION ${version}`);
}
if (!background.includes(`const BROWSER_TYPE = "${browserName}";`)) {
throw new Error(`${browserName} background.js does not contain the injected browser type`);
}
if (!content.includes('const EVENTS = {')) {
throw new Error(`${browserName} content.js does not contain injected protocol events`);
}
if (popup.includes('__BUILD_TIMESTAMP__')) {
throw new Error(`${browserName} popup.html contains an unresolved build timestamp`);
}
}
async function verify() {
const options = parseArgs(process.argv.slice(2));
const version = versionFromTag(options.tag);
const repo = options.repo || run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).trim();
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) throw new Error(`Invalid GitHub repository: ${repo}`);
const tagRef = `refs/tags/${options.tag}`;
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, 'origin/main']);
const tagCommit = run('git', ['rev-list', '-n', '1', tagRef]).trim();
const temporaryDirectory = options.assetDir
? null
: fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-release-verification-'));
const assetDirectory = options.assetDir || temporaryDirectory;
try {
if (!options.assetDir) {
const publishedAssets = run('gh', [
'release', 'view', options.tag, '--repo', repo,
'--json', 'assets', '--jq', '.assets[].name'
]).split(/\r?\n/u).filter(Boolean);
validateReleaseAssetNames(publishedAssets);
run('gh', [
'release', 'download', options.tag, '--repo', repo, '--dir', assetDirectory,
'--pattern', 'koalasync-*.zip', '--pattern', 'SHA256SUMS'
], { capture: false });
}
for (const assetName of RELEASE_ASSET_NAMES) {
const assetPath = path.join(assetDirectory, assetName);
if (!fs.statSync(assetPath, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`Missing release asset: ${assetName}`);
}
}
const checksums = parseChecksumFile(fs.readFileSync(path.join(assetDirectory, 'SHA256SUMS'), 'utf8'));
validateReleaseAssetNames([...checksums.keys(), 'SHA256SUMS']);
for (const assetName of RELEASE_ASSET_NAMES.filter(name => name.endsWith('.zip'))) {
const actual = await sha256File(path.join(assetDirectory, assetName));
const expected = checksums.get(assetName);
if (actual !== expected) throw new Error(`${assetName} checksum mismatch: expected ${expected}, got ${actual}`);
}
const archiveEntries = {};
for (const browserName of ['chrome', 'firefox']) {
const archivePath = path.join(assetDirectory, `koalasync-${browserName}.zip`);
archiveEntries[browserName] = validateArchiveEntries(browserName, listArchiveEntries(archivePath));
let manifest;
try {
manifest = JSON.parse(readArchiveText(archivePath, 'manifest.json'));
} catch (error) {
throw new Error(`${browserName} manifest.json is invalid: ${error.message}`);
}
validateManifest(browserName, manifest, version);
assertRuntimeBuild(browserName, archivePath, version);
if (!options.skipAttestation && !options.assetDir) {
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);
console.log(`Published release ${options.tag} verified for ${repo}`);
console.log(`Assets: ${RELEASE_ASSET_NAMES.join(', ')}`);
console.log(`Version: ${version}; checksums, manifests, parity${options.skipAttestation || options.assetDir ? '' : ', attestations'} passed`);
} finally {
if (temporaryDirectory) fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
verify().catch(error => {
console.error(`Published release verification failed: ${error.message}`);
process.exitCode = 1;
});
+2 -8
View File
@@ -7,22 +7,16 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
['vitest unit tests', 'npm', ['run', 'test:unit']],
['server ops', 'node', ['scripts/test-server-ops.mjs']],
['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' }
}],
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
['names generator', 'node', ['scripts/test-names.mjs']],
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['blacklist settings', 'node', ['scripts/test-blacklist-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
+3 -1
View File
@@ -87,7 +87,9 @@ The server is covered by the root verification suite. From the repository root,
npm run verify
```
For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
For focused server checks, run `npm run test:unit` for `server/ops.test.mjs`
and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
`scripts/test-server-ws.mjs` for process-level integration coverage.
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
+24 -6
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
const roomCleanupInterval = setInterval(() => {
const now = Date.now();
export function cleanupInactiveRooms(now = Date.now()) {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
}
for (const sid of staleSids) {
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
if (deadSocket) {
deadSocket.emit(EVENTS.ERROR, {
code: ERROR_CODES.PEER_TIMED_OUT,
message: 'Removed from room after inactivity'
});
deadSocket.leave(roomId);
}
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
try {
removePeerFromRoom(sid, roomId, 'reaper');
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
// 2. Prune empty or inactive rooms
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
io.to(roomId).emit(EVENTS.ERROR, {
code: ERROR_CODES.ROOM_CLOSED,
message: 'Room closed'
});
// A terminal room timeout is a real leave for every member. Clear
// the same socket/peer indexes as an explicit leave so a later join
// cannot be mistaken for the stale membership.
for (const sid of Array.from(currentRoom.peers)) {
const memberSocket = io.sockets?.sockets?.get(sid);
if (memberSocket) memberSocket.leave(roomId);
removePeerFromRoom(sid, roomId, 'room-timeout');
}
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
}
}
}, 2 * 60 * 1000);
}
const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
describe('server operational helpers', () => {
it('authorizes only an exact configured bearer token', () => {
expect(isAdminMetricsAuthorized(undefined, 'secret-token')).toBe(false);
expect(isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token')).toBe(false);
expect(isAdminMetricsAuthorized('Bearer secret-token', 'secret-token')).toBe(true);
expect(isAdminMetricsAuthorized('Bearer secret-token', '')).toBe(false);
});
it('allows disabled metrics or strong admin tokens', () => {
expect(isAdminMetricsTokenStrong('')).toBe(true);
expect(isAdminMetricsTokenStrong('short-token')).toBe(false);
expect(isAdminMetricsTokenStrong('a'.repeat(32))).toBe(true);
});
it('tracks cooldowns and expires cached payloads deterministically', () => {
const cooldowns = new Map();
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000)).toBe(true);
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000)).toBe(false);
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000)).toBe(true);
const cache = new Map();
let buildCalls = 0;
const first = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const cached = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expired = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 61_001);
expect(cached).toBe(first);
expect(expired).toEqual({ value: 2 });
});
it('keeps public health minimal and exposes aggregate admin metrics', () => {
const rooms = new Map([
['room-a', { peers: new Set(['a', 'b']), activeLobby: null }],
['room-b', { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } }]
]);
const input = {
rooms,
connections: 5,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: {
connections: 1,
events: 2,
health: 3,
adminMetricsAuth: 4,
authFailures: 5,
roomList: 6,
leaveRoom: 7
}
};
expect(Object.keys(buildHealthPayload({ ...input, includeMetrics: false })).sort()).toEqual(
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort()
);
expect(buildHealthPayload({
...input,
includeMetrics: true,
rateLimitDenied: { leaveRoom: 8 }
})).toMatchObject({
peers: 5,
roomsWithLobby: 1,
avgPeersPerRoom: 2.5,
maxPeersInRoom: 3,
memory: { rss: 10, heapUsed: 5, heapTotal: 8 },
rateLimits: {
trackedClients: input.rateLimitSizes,
denied: {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0,
leaveRoom: 8
}
}
});
});
});
+121 -13
View File
@@ -1,28 +1,55 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
checkAdminMetricsAuthRate,
checkAuthRate,
checkConnectionRate,
checkEventRate,
checkHealthRate,
checkLeaveRoomRate,
checkChatMessageRate,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT,
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,
rateLimitDenied,
leaveRoomCounts,
clearRateLimitMaps
clearRateLimitMaps,
recordAuthFailure,
startRateLimitCleanup,
stopRateLimitCleanup
} from './rate-limiter.js';
function resetRateLimits() {
stopRateLimitCleanup();
clearRateLimitMaps();
Object.assign(rateLimitDenied, {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0,
leaveRoom: 0,
chatMessages: 0
});
}
describe('LEAVE_ROOM Rate Limiter', () => {
const testSocketId = 'test-socket-123';
beforeEach(() => {
clearRateLimitMaps();
rateLimitDenied.leaveRoom = 0;
resetRateLimits();
});
afterEach(() => {
clearRateLimitMaps();
});
afterEach(resetRateLimits);
it('should allow LEAVE_ROOM within limit', () => {
// Test within the rate limit
@@ -98,7 +125,7 @@ describe('LEAVE_ROOM Rate Limiter', () => {
checkLeaveRoomRate(testSocketId);
expect(leaveRoomCounts.size).toBe(1);
clearRateLimitMaps();
resetRateLimits();
expect(leaveRoomCounts.size).toBe(0);
});
});
@@ -106,12 +133,9 @@ describe('LEAVE_ROOM Rate Limiter', () => {
describe('CHAT_MESSAGE Rate Limiter', () => {
const socketId = 'chat-socket';
beforeEach(() => {
clearRateLimitMaps();
rateLimitDenied.chatMessages = 0;
});
beforeEach(resetRateLimits);
afterEach(() => clearRateLimitMaps());
afterEach(resetRateLimits);
it('allows ten messages per ten-second window and blocks the next', () => {
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
@@ -129,6 +153,90 @@ describe('CHAT_MESSAGE Rate Limiter', () => {
});
});
describe('remaining relay rate limits', () => {
beforeEach(resetRateLimits);
afterEach(resetRateLimits);
it.each([
['connection', checkConnectionRate, CONNECTION_RATE_LIMIT, 'ip-1', 'connections'],
['event', checkEventRate, EVENT_RATE_LIMIT, 'socket-1', 'events'],
['health', checkHealthRate, 10, 'ip-2', 'health'],
['admin metrics auth', checkAdminMetricsAuthRate, 5, 'ip-3', 'adminMetricsAuth']
])('enforces the %s window and increments its denial counter', (_label, check, limit, key, counter) => {
for (let attempt = 0; attempt < limit; attempt++) expect(check(key)).toBe(true);
expect(check(key)).toBe(false);
expect(rateLimitDenied[counter]).toBe(1);
expect(check(`${key}-other`)).toBe(true);
});
it('scopes failed authentication attempts to IP and room', () => {
for (let attempt = 0; attempt < 5; attempt++) recordAuthFailure('10.0.0.1', 'room-a');
expect(checkAuthRate('10.0.0.1', 'room-a')).toBe(false);
expect(checkAuthRate('10.0.0.1', 'room-b')).toBe(true);
expect(failedAuthAttempts.get('10.0.0.1:room-a')).toMatchObject({ count: 5 });
});
it('starts cleanup only once and can stop safely', () => {
const io = { sockets: { sockets: new Map() } };
expect(() => {
startRateLimitCleanup(io);
startRateLimitCleanup(io);
stopRateLimitCleanup();
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', () => {
it('should have correct rate limit values', () => {
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
BLACKLIST_DOMAINS,
BLACKLIST_OVERRIDES_STORAGE_KEY,
BLACKLIST_SOURCE_DEFAULT,
BLACKLIST_SOURCE_USER,
CUSTOM_BLACKLIST_STORAGE_KEY,
createEmptyBlacklistOverrides,
deriveBlacklistOverrides,
getBlacklistEntries,
getEffectiveBlacklistDomains,
isUrlBlacklisted,
normalizeBlacklistDomain,
normalizeBlacklistOverrides,
parseBlacklistDomains
} from './blacklist.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
describe('blacklist behavior', () => {
it('normalizes, deduplicates, and rejects unsafe entries', () => {
expect(CUSTOM_BLACKLIST_STORAGE_KEY).toBe('customBlacklistDomains');
expect(BLACKLIST_OVERRIDES_STORAGE_KEY).toBe('blacklistOverrides');
expect(normalizeBlacklistDomain(' Example.COM. ')).toBe('example.com');
expect(normalizeBlacklistDomain('https://Video.Example.com/watch/123')).toBe('video.example.com');
expect(normalizeBlacklistDomain('*.example.com')).toBeNull();
expect(normalizeBlacklistDomain('not a domain')).toBeNull();
expect(parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n')).toEqual({
domains: ['example.com', 'sub.example.com'],
invalid: []
});
expect(parseBlacklistDomains('example.com\nnot a domain').invalid).toEqual(['not a domain']);
expect(parseBlacklistDomains('# note\nvideos.example\n\n# defaults\ngoogle.com')).toEqual({
domains: ['videos.example', 'google.com'],
invalid: []
});
});
it('matches only exact hosts and their subdomains', () => {
expect(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com'])).toBe(true);
expect(isUrlBlacklisted('https://notgoogle.com/', ['google.com'])).toBe(false);
expect(isUrlBlacklisted('not a url', ['example.com'])).toBe(false);
expect(isUrlBlacklisted('https://drive.google.com/file/d/x/view', BLACKLIST_DOMAINS)).toBe(false);
expect(isUrlBlacklisted('https://drive.google.com/file/d/x/view', ['drive.google.com'])).toBe(true);
expect(isUrlBlacklisted('https://docs.google.com/document/d/x', BLACKLIST_DOMAINS)).toBe(true);
});
it('stores user edits as a delta so future defaults continue to flow in', () => {
expect(createEmptyBlacklistOverrides()).toEqual({ removedDefaults: [], addedDomains: [] });
const edited = BLACKLIST_DOMAINS
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
.concat(['videos.example']);
const overrides = deriveBlacklistOverrides(edited);
expect(overrides).toEqual({
removedDefaults: ['reddit.com', 'imgur.com'],
addedDomains: ['videos.example']
});
const effective = new Set(getEffectiveBlacklistDomains(overrides));
expect(effective.has('reddit.com')).toBe(false);
expect(effective.has('videos.example')).toBe(true);
const removed = new Set(overrides.removedDefaults);
for (const domain of BLACKLIST_DOMAINS) {
expect(effective.has(domain) || removed.has(domain)).toBe(true);
}
const readded = deriveBlacklistOverrides([...effective, 'reddit.com'], overrides);
expect(new Set(readded.removedDefaults).has('reddit.com')).toBe(false);
expect(deriveBlacklistOverrides(['google.com'], {
removedDefaults: [],
addedDomains: ['google.com']
}).addedDomains).toEqual(['google.com']);
});
it('normalizes legacy and contradictory storage without losing intent', () => {
expect(getEffectiveBlacklistDomains(undefined)).toEqual(BLACKLIST_DOMAINS);
expect(getEffectiveBlacklistDomains([])).toEqual([]);
expect(normalizeBlacklistOverrides({
removedDefaults: ['example.com'],
addedDomains: ['example.com']
})).toEqual({ removedDefaults: [], addedDomains: ['example.com'] });
expect(normalizeBlacklistOverrides('nonsense')).toEqual(createEmptyBlacklistOverrides());
const overrides = { removedDefaults: ['reddit.com'], addedDomains: ['videos.example'] };
const entries = getBlacklistEntries(overrides);
expect(entries.find(entry => entry.domain === 'videos.example')?.source).toBe(BLACKLIST_SOURCE_USER);
expect(entries.find(entry => entry.domain === 'google.com')?.source).toBe(BLACKLIST_SOURCE_DEFAULT);
const rendered = [
'# Your entries',
...entries.filter(entry => entry.source === BLACKLIST_SOURCE_USER).map(entry => entry.domain),
'',
'# Shipped defaults',
...entries.filter(entry => entry.source === BLACKLIST_SOURCE_DEFAULT).map(entry => entry.domain)
].join('\n');
expect(deriveBlacklistOverrides(parseBlacklistDomains(rendered).domains, overrides)).toEqual(
normalizeBlacklistOverrides(overrides)
);
});
});
describe('blacklist integration contracts', () => {
it('keeps storage local and the editor present', () => {
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
expect(popupSource).toMatch(/chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/);
expect(popupSource).not.toMatch(/chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/);
expect(popupSource).toMatch(/chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/);
expect(popupSource).toMatch(/isUrlBlacklisted\(tab\.url, blacklistDomains\)/);
expect(popupHtml).toMatch(/id="blacklistDomains"/);
expect(popupHtml).toMatch(/id="blacklistReset"/);
});
});
+9 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "3.1.4";
export const APP_VERSION = "3.1.5";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -65,6 +65,14 @@ export const EVENTS = {
PONG: "pong" // server responds with same { t } for client RTT calculation
};
// Stable server error identifiers. Clients must branch on these codes instead
// of localized or user-facing message text whenever the error changes session
// state.
export const ERROR_CODES = {
ROOM_CLOSED: 'room_closed',
PEER_TIMED_OUT: 'peer_timed_out'
};
// Room control modes (Host Control Mode feature).
// NOTE: content.js does not import this module — it uses the string literals
// 'everyone' / 'host-only' directly. Keep these values in sync there.
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { generateUsername, getAvatarForName, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './names.js';
describe('generated peer names', () => {
it.each([
['Koala', '🐨'],
['koala', '🐨'],
['MyKoalaUser', '🐨'],
['Tiger', '🐯'],
['Panda', '🐼'],
['Fox', '🦊'],
['CaterpillarCat', '🐛'],
['Cat', '🐱'],
['Polar', '🐻\u200D❄️'],
['Crow', '🐦\u200D⬛'],
['Ninja', '🥷'],
['Wizard', '🧙'],
['Pirate', '🏴'],
['Alien', '👾'],
['Robot', '🤖']
])('maps %s to %s', (name, avatar) => {
expect(getAvatarForName(name)).toBe(avatar);
});
it.each(['', 'Xyzzy123', null, undefined])('uses the fallback for %j', name => {
expect(getAvatarForName(name)).toBe('👤');
});
it('generates only adjective-noun combinations', () => {
for (let sample = 0; sample < 100; sample++) {
const name = generateUsername();
expect(name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
expect(USERNAME_ADJECTIVES.some(adjective => name.startsWith(adjective))).toBe(true);
expect(USERNAME_NOUNS.some(noun => name.endsWith(noun))).toBe(true);
}
});
it('defines an avatar for every generated noun', () => {
for (const noun of USERNAME_NOUNS) expect(getAvatarForName(noun)).not.toBe('👤');
});
});
+12
View File
@@ -8,6 +8,9 @@ enough to control it.
npm run test:e2e:install # once, downloads the browsers
npm run build:extension # extension.spec.mjs loads dist/chrome
npm run test:e2e
npm run test:e2e:detection # finder only: Chromium, Firefox, WebKit
npm run test:e2e:extension # packed extension only: Chromium MV3
npm run test:e2e:race # @race scenarios, repeated 20 times
```
## Layout
@@ -16,11 +19,19 @@ npm run test:e2e
| :--- | :--- |
| `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) |
| `helpers/content-source.mjs` | Lifts the real finder out of `extension/content.js` |
The detection fixtures run as three Playwright projects: Chromium, Firefox,
and WebKit. Packed-extension tests remain Chromium-only because they exercise
Chrome MV3 APIs and a persistent service-worker context. The scheduled
`.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads
traces/results on failure.
## Two rules worth keeping
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
@@ -45,6 +56,7 @@ reads as a broken fixture instead of a scoring regression.
| `late-frame.html` | Player frame attached after the page settled |
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
| `muted-player.html` | Mute must not disqualify the only player |
| `display-contents-player.html` | A visible player survives a boxless `display: contents` wrapper |
| `hidden-preload.html` | A `display:none` preload still reports 1080p; it must lose |
| `ad-frame.html` | 1080p asset in a 300x250 ad slot must lose to the real player |
| `background-loop.html` | Silent looping hero must lose despite being the largest |
+11 -11
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`;
@@ -737,7 +737,7 @@ test('polling video state on a page with no video does not restart the target',
.toBe('true');
});
test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test('@race stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Live ad churn wakes the media-frame monitor several times a second. Each
// wake used to schedule a trailing refresh that rebuilt the target
@@ -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(() => {});
}
}
+65
View File
@@ -0,0 +1,65 @@
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 remainingMs = Math.max(1, deadline - Date.now());
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: globalThis.AbortSignal.timeout(Math.min(1000, remainingMs))
});
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('')}`);
}
+30 -8
View File
@@ -12,19 +12,41 @@ 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: {
baseURL: `http://localhost:${PORT}`,
trace: 'retain-on-failure',
launchOptions: {
// Several fixtures hinge on a video actually playing. Without this
// the browser's autoplay heuristics decide whether the fixture is
// valid, which shows up later as an unexplained flake.
args: ['--autoplay-policy=no-user-gesture-required']
}
trace: 'retain-on-failure'
},
projects: [
{
name: 'detection-chromium',
testMatch: 'detection.spec.mjs',
use: {
browserName: 'chromium',
launchOptions: {
// Chromium alone supports this switch. Passing it through
// the shared config makes Linux WebKit refuse to launch.
args: ['--autoplay-policy=no-user-gesture-required']
}
}
},
{
name: 'detection-firefox',
testMatch: 'detection.spec.mjs',
use: { browserName: 'firefox' }
},
{
name: 'detection-webkit',
testMatch: 'detection.spec.mjs',
use: { browserName: 'webkit' }
},
{
name: 'extension-chromium',
testIgnore: 'detection.spec.mjs'
}
],
webServer: {
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
url: `http://localhost:${PORT}/pages/simple-player.html`,
+60
View File
@@ -0,0 +1,60 @@
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();
});
test('popup root remains 360px when a dynamic child overflows', async ({ context, extensionId }) => {
const page = await openPopup(context, extensionId, { openEditor: false });
const geometry = await page.evaluate(() => {
const probe = document.createElement('div');
probe.id = 'popup-overflow-probe';
probe.style.width = '1200px';
probe.style.height = '1px';
document.body.appendChild(probe);
const htmlStyle = window.getComputedStyle(document.documentElement);
const bodyStyle = window.getComputedStyle(document.body);
return {
htmlWidth: document.documentElement.getBoundingClientRect().width,
bodyWidth: document.body.getBoundingClientRect().width,
htmlOverflowX: htmlStyle.overflowX,
bodyOverflowX: bodyStyle.overflowX,
bodyContain: bodyStyle.contain,
probeWidth: probe.getBoundingClientRect().width
};
});
expect(geometry).toEqual({
htmlWidth: 360,
bodyWidth: 360,
htmlOverflowX: 'hidden',
bodyOverflowX: 'hidden',
bodyContain: 'inline-size',
probeWidth: 1200
});
});
+118
View File
@@ -0,0 +1,118 @@
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();
let relay = null;
try {
const port = await reservePort();
relay = await startRelay(port);
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();
}
});
+40 -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: {
@@ -10,17 +11,50 @@ export default defineConfig({
'shared/**/*.test.js',
'shared/**/*.test.mjs',
'extension/**/*.test.js',
'extension/**/*.test.mjs'
'extension/**/*.test.mjs',
'scripts/**/*.test.mjs'
],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['server/**/*.js', 'shared/**/*.js'],
// Coverage is intentionally scoped to importable modules exercised
// 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: VITEST_COVERAGE_INCLUDE,
exclude: [
'**/node_modules/**',
'**/scripts/**',
'**/extension/**'
]
'**/node_modules/**'
],
thresholds: {
statements: 80,
branches: 68,
functions: 85,
lines: 83,
'extension/media-frame-target.js': {
statements: 65,
branches: 50,
functions: 75,
lines: 67
},
'extension/host-access.js': {
statements: 77,
branches: 66,
functions: 81,
lines: 79
},
'server/rate-limiter.js': {
statements: 70,
branches: 58,
functions: 83,
lines: 74
},
'scripts/release-artifact-checks.mjs': {
statements: 100,
branches: 95,
functions: 100,
lines: 100
}
}
}
}
});
+1 -1
View File
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
## Technical information
- Current website release: 3.1.4
- Current website release: 3.1.5
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
+1 -1
View File
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "3.1.4",
"softwareVersion": "3.1.5",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "3.1.4",
"date": "2026-08-21T10:33:30Z"
"version": "3.1.5",
"date": "2026-08-24T21:56:28Z"
}