mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-29 03:57:09 +00:00
Merge pull request #37 from Shik3i/codex/test-suite-hardening
Release v3.1.5: harden teardown and popup containment
This commit is contained in:
@@ -47,6 +47,36 @@ jobs:
|
|||||||
- name: Run verification suite
|
- name: Run verification suite
|
||||||
run: npm run verify
|
run: npm run verify
|
||||||
|
|
||||||
|
node20:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up lowest supported Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20.19.0'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
server/package-lock.json
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install server dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server
|
||||||
|
|
||||||
|
- name: Run unit and relay integration gates
|
||||||
|
run: |
|
||||||
|
npm run test:coverage
|
||||||
|
node scripts/test-server-routes.mjs
|
||||||
|
node scripts/test-server-ws.mjs
|
||||||
|
env:
|
||||||
|
ADMIN_METRICS_TOKEN: verify-admin-token-with-more-than-32-chars
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
# Kept separate from `verify`: this job needs a downloaded browser, so a
|
# Kept separate from `verify`: this job needs a downloaded browser, so a
|
||||||
# failure here should read as "the browser flow broke", not as a broken
|
# failure here should read as "the browser flow broke", not as a broken
|
||||||
@@ -66,12 +96,27 @@ jobs:
|
|||||||
- name: Install root dependencies
|
- name: Install root dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
- name: Install server dependencies
|
||||||
run: npx playwright install --with-deps chromium chromium-headless-shell
|
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.
|
# The extension specs load dist/chrome, so the artifact has to exist.
|
||||||
- name: Build the extension
|
- name: Build the extension
|
||||||
run: npm run build: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
|
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
|
||||||
|
|||||||
@@ -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
@@ -5,14 +5,139 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
# A release run must never be interrupted (it commits back to main and publishes
|
|
||||||
# artifacts). Only dedupe accidental re-pushes of the same tag.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref_name }}
|
group: release-${{ github.ref_name }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: read
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release-ref.outputs.version }}
|
||||||
|
tag-commit: ${{ steps.release-ref.outputs.tag_commit }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout release tag
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
server/package-lock.json
|
||||||
|
|
||||||
|
- name: Validate annotated tag, main commit, and required checks
|
||||||
|
id: release-ref
|
||||||
|
run: node scripts/release-preflight.mjs
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install server dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server
|
||||||
|
|
||||||
|
- name: Run complete release verification
|
||||||
|
run: npm run verify
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit
|
||||||
|
|
||||||
|
- name: Run browser E2E suite
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
- name: Build relay container without publishing
|
||||||
|
run: docker build --file server/Dockerfile --tag koalasync-release-preflight .
|
||||||
|
|
||||||
|
- name: Smoke-test relay container
|
||||||
|
run: |
|
||||||
|
CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-preflight-salt-with-more-than-thirty-two-chars koalasync-release-preflight)
|
||||||
|
trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT
|
||||||
|
HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://')
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker logs "$CONTAINER_ID"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
release-extension-draft:
|
||||||
|
needs: preflight
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout release tag
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies and build release artifacts
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build:extension
|
||||||
|
node website/build.cjs
|
||||||
|
|
||||||
|
- name: Validate Firefox package
|
||||||
|
run: npx addons-linter --warnings-as-errors dist/koalasync-firefox.zip
|
||||||
|
|
||||||
|
- name: Generate extension checksums
|
||||||
|
working-directory: dist
|
||||||
|
run: sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS
|
||||||
|
|
||||||
|
- name: Validate release assets before publication
|
||||||
|
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --asset-dir dist --skip-attestation
|
||||||
|
|
||||||
|
- name: Attest extension archives
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-path: dist/koalasync-*.zip
|
||||||
|
|
||||||
|
- name: Create draft GitHub release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
dist/koalasync-chrome.zip
|
||||||
|
dist/koalasync-firefox.zip
|
||||||
|
dist/SHA256SUMS
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Verify draft extension release
|
||||||
|
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload website artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: website-www
|
||||||
|
path: website/www/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
release-server:
|
release-server:
|
||||||
|
needs: [preflight, release-extension-draft]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -20,7 +145,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout release tag
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -52,126 +177,46 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
# Reuse layers across releases to speed up the multi-arch build.
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Attest relay image
|
||||||
uses: actions/attest@v4
|
uses: actions/attest@v4
|
||||||
with:
|
with:
|
||||||
subject-name: ghcr.io/${{ github.repository }}
|
subject-name: ghcr.io/${{ github.repository }}
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
release-extension:
|
- name: Verify manifest, provenance, and running image
|
||||||
|
env:
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
IMAGE: ghcr.io/${{ github.repository }}
|
||||||
|
SOURCE_DIGEST: ${{ needs.preflight.outputs.tag-commit }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect "$IMAGE@$DIGEST" --raw > /tmp/koalasync-manifest.json
|
||||||
|
node -e 'const m=require("/tmp/koalasync-manifest.json"); const p=new Set(m.manifests.map(x=>`${x.platform.os}/${x.platform.architecture}`)); for (const x of ["linux/amd64","linux/arm64"]) if(!p.has(x)) throw new Error(`missing platform ${x}`)'
|
||||||
|
gh attestation verify "oci://$IMAGE@$DIGEST" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" --source-ref "$GITHUB_REF" --source-digest "$SOURCE_DIGEST" --deny-self-hosted-runners
|
||||||
|
docker pull --platform linux/amd64 "$IMAGE@$DIGEST"
|
||||||
|
CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-smoke-salt-with-more-than-thirty-two-chars "$IMAGE@$DIGEST")
|
||||||
|
trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT
|
||||||
|
HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://')
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker logs "$CONTAINER_ID"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
finalize-release:
|
||||||
|
needs: [preflight, release-extension-draft, release-server]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Publish verified GitHub release
|
||||||
uses: actions/checkout@v7
|
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Inject version into source files
|
|
||||||
run: |
|
|
||||||
VERSION=${{ steps.version.outputs.VERSION }}
|
|
||||||
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..."
|
|
||||||
|
|
||||||
# 1. extension/manifest.base.json
|
|
||||||
jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json
|
|
||||||
echo " ✓ manifest.base.json -> $VERSION"
|
|
||||||
|
|
||||||
# 2. shared/constants.js — APP_VERSION
|
|
||||||
sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js
|
|
||||||
echo " ✓ shared/constants.js -> $VERSION"
|
|
||||||
|
|
||||||
# 3. package.json
|
|
||||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
|
||||||
echo " ✓ package.json -> $VERSION"
|
|
||||||
|
|
||||||
# 4. package-lock.json root package metadata
|
|
||||||
jq --arg v "$VERSION" '.version = $v | .packages[""].version = $v' package-lock.json > tmp.json && mv tmp.json package-lock.json
|
|
||||||
echo " ✓ package-lock.json -> $VERSION"
|
|
||||||
|
|
||||||
# 5. website/version.json
|
|
||||||
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
|
|
||||||
echo " ✓ website/version.json -> version $VERSION, date $DATE"
|
|
||||||
|
|
||||||
# 6. website/template.html — SoftwareApplication schema
|
|
||||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
|
||||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
|
||||||
|
|
||||||
# 7. website/llms.txt — machine-readable release metadata
|
|
||||||
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
|
|
||||||
echo " ✓ website/llms.txt -> $VERSION"
|
|
||||||
|
|
||||||
# 8. README.md — version badge & banner
|
|
||||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
|
||||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
|
||||||
echo " ✓ README.md -> v$VERSION"
|
|
||||||
|
|
||||||
echo "Version injection complete."
|
|
||||||
|
|
||||||
- name: Commit and push version updates back to main
|
|
||||||
run: |
|
|
||||||
git config --local user.email "action@github.com"
|
|
||||||
git config --local user.name "GitHub Action"
|
|
||||||
git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md
|
|
||||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
|
||||||
git push origin HEAD:main
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_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 }}
|
|
||||||
|
|||||||
+4
-2
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Node.js** v20.9+
|
- **Node.js** v20.19+
|
||||||
- **Docker** (for local relay server testing)
|
- **Docker** (for local relay server testing)
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
@@ -96,7 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
|
|||||||
## Version Numbers
|
## Version Numbers
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts.
|
> **Never edit release versions independently.** Release maintainers run
|
||||||
|
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the
|
||||||
|
> resulting version changes through a CI-green pull request before tagging.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<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/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="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://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>
|
<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
@@ -118,17 +118,23 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
|||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||||
>
|
>
|
||||||
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
|
> **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`.
|
||||||
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
|
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
|
||||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||||
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
||||||
2. Commit all verified code changes and push to `main`.
|
2. Commit the prepared version and release-note changes on a branch, push it,
|
||||||
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
|
open a pull request, and wait for required `verify`, `node20`, and `e2e`
|
||||||
|
checks. Direct pushes to `main` are not part of the release process.
|
||||||
|
3. After the PR is merged, update local `main` and create an annotated exact
|
||||||
|
SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as
|
||||||
|
`origin/main`.
|
||||||
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
|
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
|
||||||
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
|
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
|
||||||
4. The CI will extract the version from the tag (e.g., `v1.4.0` → `1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
|
4. The release workflow validates the unchanged tagged source, creates a draft
|
||||||
5. Verify the release builds on GitHub Actions.
|
release, publishes and verifies the relay image, and makes the release public
|
||||||
|
only after every gate succeeds.
|
||||||
|
5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
|
||||||
|
|
||||||
### 🚫 Force Push Policy
|
### 🚫 Force Push Policy
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
|
|||||||
+4
-1
@@ -35,7 +35,10 @@ The build script performs the following actions:
|
|||||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||||
- The version is defined in `shared/constants.js`.
|
- The version is defined in `shared/constants.js`.
|
||||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||||
- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates.
|
- Never edit release versions independently. Run
|
||||||
|
`npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch;
|
||||||
|
the release tag is accepted only after that change reaches `main` with all CI
|
||||||
|
checks passing.
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||||
|
|||||||
+36
-23
@@ -4,30 +4,32 @@ This document describes the deployment and release process for KoalaSync.
|
|||||||
|
|
||||||
## Tag-Based Releases
|
## Tag-Based Releases
|
||||||
|
|
||||||
KoalaSync uses a fully automated release pipeline triggered by Git tags.
|
KoalaSync uses a gated release pipeline triggered by immutable Git tags.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **DO NOT** manually bump the version numbers in any files (such as `package.json`, `manifest.base.json`, `shared/constants.js`, etc.) before creating a release.
|
> **DO NOT** edit individual version files or tag an unmerged branch. Run
|
||||||
> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline.
|
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated
|
||||||
|
> source changes, and merge them through a pull request with successful CI.
|
||||||
|
|
||||||
### How it Works
|
### How it Works
|
||||||
|
|
||||||
When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered. The workflow performs the following actions:
|
When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub
|
||||||
|
Actions workflow performs these ordered gates:
|
||||||
|
|
||||||
1. **Extracts the version** from the tag (e.g., `2.5.1` from `v2.5.1`).
|
1. Confirms that the tag is annotated, points exactly at current `origin/main`,
|
||||||
2. **Injects the version** automatically into the following files:
|
and matches every committed version source.
|
||||||
- `extension/manifest.base.json`
|
2. Requires successful `verify`, `node20`, and `e2e` checks for that commit.
|
||||||
- `shared/constants.js` (updates `APP_VERSION`)
|
3. Re-runs release verification, cross-browser E2E, and an unpublished relay
|
||||||
- `package.json`
|
container smoke test.
|
||||||
- `package-lock.json` (root package metadata)
|
4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output,
|
||||||
- `website/version.json`
|
website output, archive parity, and manifests.
|
||||||
- `website/template.html` (updates `softwareVersion` schema)
|
5. Creates an attested **draft** GitHub Release.
|
||||||
- `README.md` (updates badge and announcement banner)
|
6. Publishes the multi-architecture relay image, verifies both platforms,
|
||||||
- `website/sitemap.xml` (updates `lastmod` dates)
|
attestation identity, digest, tag source, and a running health check.
|
||||||
3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`.
|
7. Makes the GitHub Release public only after every preceding gate succeeds.
|
||||||
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations.
|
|
||||||
5. **Builds the website** and uploads website artifacts.
|
The release workflow never writes to `main` and never derives shell code from a
|
||||||
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`).
|
tag. Version changes must pass normal branch protection first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,18 +37,29 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea
|
|||||||
|
|
||||||
To release a new version (e.g., `v2.5.1`), follow these steps:
|
To release a new version (e.g., `v2.5.1`), follow these steps:
|
||||||
|
|
||||||
1. Make sure your local repository is synced on `main`:
|
1. Create a release-preparation branch from current `main` and update every
|
||||||
|
version source atomically:
|
||||||
```bash
|
```bash
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
git checkout -b release/v2.5.1
|
||||||
|
npm run prepare:release -- 2.5.1
|
||||||
|
npm run verify
|
||||||
```
|
```
|
||||||
2. Create a local Git tag:
|
2. Commit the release notes and prepared version changes, open a pull request,
|
||||||
|
and wait for required `verify`, `node20`, and `e2e` checks.
|
||||||
|
3. After the PR is merged, fast-forward local `main` and create an **annotated**
|
||||||
|
tag on that exact commit:
|
||||||
```bash
|
```bash
|
||||||
git tag v2.5.1
|
git checkout main
|
||||||
|
git pull --ff-only origin main
|
||||||
|
git tag -a v2.5.1 -m "Release v2.5.1"
|
||||||
```
|
```
|
||||||
3. Push the tag to GitHub:
|
4. Verify the tag target, then push it once:
|
||||||
```bash
|
```bash
|
||||||
|
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
|
||||||
git push origin v2.5.1
|
git push origin v2.5.1
|
||||||
```
|
```
|
||||||
|
|
||||||
The release pipeline will take care of the rest! You can monitor the progress under the **Actions** tab of the GitHub repository.
|
Never reuse or move a published tag. Monitor every release job and verify both
|
||||||
|
the public GitHub assets and GHCR digest before calling the release complete.
|
||||||
|
|||||||
+1
-2
@@ -67,8 +67,7 @@ Useful focused checks from the repository root:
|
|||||||
node -c extension/background.js
|
node -c extension/background.js
|
||||||
node -c extension/content.js
|
node -c extension/content.js
|
||||||
node -c extension/popup.js
|
node -c extension/popup.js
|
||||||
node scripts/test-episode-utils.mjs
|
npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs
|
||||||
node scripts/test-title-privacy.mjs
|
|
||||||
node scripts/test-audio-settings.mjs
|
node scripts/test-audio-settings.mjs
|
||||||
node scripts/test-locales.cjs
|
node scripts/test-locales.cjs
|
||||||
```
|
```
|
||||||
|
|||||||
+50
-69
@@ -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 { generateUsername } from './shared/names.js';
|
||||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||||
@@ -988,15 +988,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveRoomAfterIdleGrace(reason) {
|
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
|
||||||
if (!currentRoom) return;
|
webJoinCoordinator.invalidate();
|
||||||
connectIntent = false;
|
connectIntent = false;
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
reconnectStartTime = null;
|
||||||
completeForceSyncBeforeTargetChange(null);
|
completeForceSyncBeforeTargetChange(null);
|
||||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||||
forceDisconnect();
|
|
||||||
|
// 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;
|
currentRoom = null;
|
||||||
clearChatActivity();
|
clearChatActivity();
|
||||||
controlMode = CONTROL_MODES.EVERYONE;
|
controlMode = CONTROL_MODES.EVERYONE;
|
||||||
@@ -1007,15 +1011,22 @@ async function leaveRoomAfterIdleGrace(reason) {
|
|||||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||||
broadcastControlMode();
|
broadcastControlMode();
|
||||||
if (currentTabId) await deactivateTargetTab(currentTabId);
|
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
||||||
invalidateTargetActivations();
|
invalidateTargetActivations();
|
||||||
currentTabId = null;
|
currentTabId = null;
|
||||||
currentTabTitle = null;
|
currentTabTitle = null;
|
||||||
clearCurrentContentTarget();
|
clearCurrentContentTarget();
|
||||||
roomIdleSince = null;
|
roomIdleSince = null;
|
||||||
lastContentHeartbeatAt = null;
|
lastContentHeartbeatAt = null;
|
||||||
clearEpisodeLobbyState();
|
|
||||||
await clearPendingTarget();
|
await clearPendingTarget();
|
||||||
|
|
||||||
|
isForceSyncInitiator = false;
|
||||||
|
forceSyncAcks.clear();
|
||||||
|
expectedAcksCount = 0;
|
||||||
|
if (forceSyncTimeout) {
|
||||||
|
clearTimeout(forceSyncTimeout);
|
||||||
|
forceSyncTimeout = null;
|
||||||
|
}
|
||||||
await chrome.storage.session.set({
|
await chrome.storage.session.set({
|
||||||
currentRoom: null,
|
currentRoom: null,
|
||||||
chatActivityTimeline: [],
|
chatActivityTimeline: [],
|
||||||
@@ -1026,17 +1037,30 @@ async function leaveRoomAfterIdleGrace(reason) {
|
|||||||
currentTargetHasVideo: false,
|
currentTargetHasVideo: false,
|
||||||
roomIdleSince: null,
|
roomIdleSince: null,
|
||||||
lastContentHeartbeatAt: null,
|
lastContentHeartbeatAt: null,
|
||||||
|
isForceSyncInitiator: false,
|
||||||
|
forceSyncAcks: [],
|
||||||
|
forceSyncDeadline: null,
|
||||||
|
expectedAcksCount: 0,
|
||||||
episodeLobby: null,
|
episodeLobby: null,
|
||||||
hcmDesynced: false
|
hcmDesynced: false,
|
||||||
|
reconnectFailed: false,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
reconnectStartTime: null
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
chatSecretGuard = '';
|
chatSecretGuard = '';
|
||||||
invalidateChatSession();
|
invalidateChatSession();
|
||||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||||
addLog(reason, 'info');
|
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
|
forceDisconnect();
|
||||||
|
addLog(reason, 'info');
|
||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function leaveRoomAfterIdleGrace(reason) {
|
||||||
|
if (!currentRoom) return;
|
||||||
|
await endRoomSession({ notifyServer: true, reason });
|
||||||
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (isConnecting) return;
|
if (isConnecting) return;
|
||||||
isConnecting = true;
|
isConnecting = true;
|
||||||
@@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) {
|
|||||||
}
|
}
|
||||||
case EVENTS.ERROR:
|
case EVENTS.ERROR:
|
||||||
isConnecting = false;
|
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,
|
// If we get a server error before successfully joining a room,
|
||||||
// clear persisted credentials as well, otherwise service-worker
|
// clear persisted credentials as well, otherwise service-worker
|
||||||
// restart would immediately retry the rejected room.
|
// restart would immediately retry the rejected room.
|
||||||
@@ -3340,6 +3371,14 @@ async function selectedMediaTargetMoved(tabId) {
|
|||||||
}
|
}
|
||||||
return false;
|
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;
|
if (currentTargetHasVideo !== true) return true;
|
||||||
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|
||||||
|| (typeof resolved.documentId === 'string'
|
|| (typeof resolved.documentId === 'string'
|
||||||
@@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'LEAVE_ROOM') {
|
} else if (message.type === 'LEAVE_ROOM') {
|
||||||
webJoinCoordinator.invalidate();
|
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||||
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();
|
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'CLEAR_LOGS') {
|
} else if (message.type === 'CLEAR_LOGS') {
|
||||||
logs = [];
|
logs = [];
|
||||||
|
|||||||
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
|
|||||||
const secret = generateChatSecret(webcrypto);
|
const secret = generateChatSecret(webcrypto);
|
||||||
let deriveCalls = 0;
|
let deriveCalls = 0;
|
||||||
let releaseDerive;
|
let releaseDerive;
|
||||||
|
let markDeriveStarted;
|
||||||
|
const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
|
||||||
const delayedCrypto = {
|
const delayedCrypto = {
|
||||||
...webcrypto,
|
...webcrypto,
|
||||||
subtle: {
|
subtle: {
|
||||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||||
deriveKey: async (...args) => {
|
deriveKey: async (...args) => {
|
||||||
deriveCalls++;
|
deriveCalls++;
|
||||||
|
markDeriveStarted();
|
||||||
await new Promise(resolve => { releaseDerive = resolve; });
|
await new Promise(resolve => { releaseDerive = resolve; });
|
||||||
return webcrypto.subtle.deriveKey(...args);
|
return webcrypto.subtle.deriveKey(...args);
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
|
|||||||
};
|
};
|
||||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||||
await Promise.resolve();
|
await deriveStarted;
|
||||||
expect(deriveCalls).toBe(1);
|
expect(deriveCalls).toBe(1);
|
||||||
clearChatKeyCache();
|
clearChatKeyCache();
|
||||||
releaseDerive();
|
releaseDerive();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"name": "__MSG_appName__",
|
"name": "__MSG_appName__",
|
||||||
"short_name": "KoalaSync",
|
"short_name": "KoalaSync",
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"description": "__MSG_appDesc__",
|
"description": "__MSG_appDesc__",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
|
|||||||
documentId,
|
documentId,
|
||||||
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
||||||
hasVideo: !!selected?.result?.bestVideo,
|
hasVideo: !!selected?.result?.bestVideo,
|
||||||
|
visibilityConfirmed: selected?.result?.parentFrameVisible === true,
|
||||||
scriptTarget: documentId
|
scriptTarget: documentId
|
||||||
? { tabId, documentIds: [documentId] }
|
? { tabId, documentIds: [documentId] }
|
||||||
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
documentId: 'document-8',
|
documentId: 'document-8',
|
||||||
frameUrl: 'https://player-8.example/embed',
|
frameUrl: 'https://player-8.example/embed',
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
|
visibilityConfirmed: true,
|
||||||
// Reported back so the caller can address these frames directly when
|
// Reported back so the caller can address these frames directly when
|
||||||
// a later all-frames sweep is rejected wholesale.
|
// a later all-frames sweep is rejected wholesale.
|
||||||
discoveredFrameIds: [0, 8],
|
discoveredFrameIds: [0, 8],
|
||||||
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
documentId: null,
|
documentId: null,
|
||||||
frameUrl: null,
|
frameUrl: null,
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
|
visibilityConfirmed: false,
|
||||||
discoveredFrameIds: [0, 6],
|
discoveredFrameIds: [0, 6],
|
||||||
scriptTarget: { tabId: 42 }
|
scriptTarget: { tabId: 42 }
|
||||||
});
|
});
|
||||||
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
|
|||||||
documentId: null,
|
documentId: null,
|
||||||
frameUrl: null,
|
frameUrl: null,
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
|
visibilityConfirmed: false,
|
||||||
discoveredFrameIds: [0],
|
discoveredFrameIds: [0],
|
||||||
scriptTarget: { tabId: 42 }
|
scriptTarget: { tabId: 42 }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -180,11 +180,25 @@
|
|||||||
color-scheme: light;
|
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 {
|
body {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
|
max-width: 360px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
contain: inline-size;
|
||||||
|
overflow-x: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
@@ -1413,16 +1427,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs" role="tablist" aria-label="KoalaSync sections">
|
||||||
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
|
<button id="tab-room-button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-room" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
|
||||||
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
<button id="tab-sync-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-sync" tabindex="-1" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
||||||
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
<button id="tab-settings-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-settings" tabindex="-1" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
||||||
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
<button id="tab-dev-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-dev" tabindex="-1" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
||||||
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
|
<button id="devToolsTabBtn" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-devtools" tabindex="-1" data-tab="tab-devtools" style="display:none;">Dev</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room Tab -->
|
<!-- Room Tab -->
|
||||||
<div id="tab-room" class="tab-content active">
|
<div id="tab-room" class="tab-content active" role="tabpanel" aria-labelledby="tab-room-button">
|
||||||
|
|
||||||
<!-- JOIN SECTION: Visible when not in a room -->
|
<!-- JOIN SECTION: Visible when not in a room -->
|
||||||
<div id="section-join">
|
<div id="section-join">
|
||||||
@@ -1510,7 +1524,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sync Tab -->
|
<!-- Sync Tab -->
|
||||||
<div id="tab-sync" class="tab-content">
|
<div id="tab-sync" class="tab-content" role="tabpanel" aria-labelledby="tab-sync-button" aria-hidden="true">
|
||||||
<!-- SYNC ACTIVE: Visible when in a room -->
|
<!-- SYNC ACTIVE: Visible when in a room -->
|
||||||
<div id="sync-active">
|
<div id="sync-active">
|
||||||
<div class="form-group" style="position: relative;">
|
<div class="form-group" style="position: relative;">
|
||||||
@@ -1576,7 +1590,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div id="tab-settings" class="tab-content">
|
<div id="tab-settings" class="tab-content" role="tabpanel" aria-labelledby="tab-settings-button" aria-hidden="true">
|
||||||
<details class="form-group" name="settings-accordion" open>
|
<details class="form-group" name="settings-accordion" open>
|
||||||
<summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile & Appearance</summary>
|
<summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile & Appearance</summary>
|
||||||
<div class="details-content">
|
<div class="details-content">
|
||||||
@@ -1813,7 +1827,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dev Tab -->
|
<!-- Dev Tab -->
|
||||||
<div id="tab-dev" class="tab-content">
|
<div id="tab-dev" class="tab-content" role="tabpanel" aria-labelledby="tab-dev-button" aria-hidden="true">
|
||||||
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
||||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||||
<span id="connDot" class="status-dot status-offline"></span>
|
<span id="connDot" class="status-dot status-offline"></span>
|
||||||
@@ -1845,7 +1859,7 @@
|
|||||||
<div id="logList"></div>
|
<div id="logList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-devtools" class="tab-content">
|
<div id="tab-devtools" class="tab-content" role="tabpanel" aria-labelledby="devToolsTabBtn" aria-hidden="true">
|
||||||
<label>Remote Seek</label>
|
<label>Remote Seek</label>
|
||||||
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
|
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
|
||||||
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
|
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
|
||||||
|
|||||||
+26
-2
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
|
|||||||
|
|
||||||
elements.tabs.forEach(btn => {
|
elements.tabs.forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
elements.tabs.forEach(b => b.classList.remove('active'));
|
elements.tabs.forEach(b => {
|
||||||
elements.contents.forEach(c => c.classList.remove('active'));
|
b.classList.remove('active');
|
||||||
|
b.setAttribute('aria-selected', 'false');
|
||||||
|
b.tabIndex = -1;
|
||||||
|
});
|
||||||
|
elements.contents.forEach(c => {
|
||||||
|
c.classList.remove('active');
|
||||||
|
c.setAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
btn.setAttribute('aria-selected', 'true');
|
||||||
|
btn.tabIndex = 0;
|
||||||
|
|
||||||
const targetContent = document.getElementById(btn.dataset.tab);
|
const targetContent = document.getElementById(btn.dataset.tab);
|
||||||
targetContent.classList.add('active');
|
targetContent.classList.add('active');
|
||||||
|
targetContent.removeAttribute('aria-hidden');
|
||||||
|
|
||||||
targetContent.classList.remove('tab-active-animate');
|
targetContent.classList.remove('tab-active-animate');
|
||||||
void targetContent.offsetWidth; // Force reflow to restart animation
|
void targetContent.offsetWidth; // Force reflow to restart animation
|
||||||
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
|
|||||||
|
|
||||||
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('keydown', event => {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||||
|
const visibleTabs = [...elements.tabs].filter(tab => window.getComputedStyle(tab).display !== 'none');
|
||||||
|
const currentIndex = visibleTabs.indexOf(btn);
|
||||||
|
const nextIndex = event.key === 'Home'
|
||||||
|
? 0
|
||||||
|
: event.key === 'End'
|
||||||
|
? visibleTabs.length - 1
|
||||||
|
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + visibleTabs.length) % visibleTabs.length;
|
||||||
|
event.preventDefault();
|
||||||
|
visibleTabs[nextIndex].focus();
|
||||||
|
visibleTabs[nextIndex].click();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
function showToast(message, type = 'info', duration = 3000) {
|
||||||
|
|||||||
@@ -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 overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||||
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.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 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', () => {
|
describe('target tab lifecycle', () => {
|
||||||
it('injects playback and chat scripts only into the explicitly selected tab', () => {
|
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'");
|
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', () => {
|
it('removes monitors injected by a superseded cross-tab activation', () => {
|
||||||
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
|
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
|
||||||
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
|
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "koalasync",
|
"name": "koalasync",
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "koalasync",
|
"name": "koalasync",
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.0",
|
"@playwright/test": "^1.62.0",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|||||||
+8
-3
@@ -1,21 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "koalasync",
|
"name": "koalasync",
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"description": "KoalaSync Build Scripts",
|
"description": "KoalaSync Build Scripts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:extension": "node scripts/build-extension.cjs",
|
"build:extension": "node scripts/build-extension.cjs",
|
||||||
"indexnow": "node website/submit-indexnow.cjs",
|
"indexnow": "node website/submit-indexnow.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
|
"prepare:release": "node scripts/prepare-release.mjs",
|
||||||
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
||||||
"test": "npm run verify",
|
"test": "npm run verify",
|
||||||
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
|
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
|
||||||
"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",
|
"test:unit": "vitest run",
|
||||||
"verify": "node scripts/verify-release.mjs"
|
"verify": "node scripts/verify-release.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-8
@@ -9,12 +9,16 @@ npm run build:extension
|
|||||||
npm run verify
|
npm run verify
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run test:unit
|
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 build:extension` runs `scripts/build-extension.cjs`.
|
||||||
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
|
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
|
||||||
- `npm run lint` runs ESLint across the repository.
|
- `npm run lint` runs ESLint across the repository.
|
||||||
- `npm run test:unit` runs Vitest tests.
|
- `npm run test:unit` runs Vitest tests.
|
||||||
|
- `npm run test:coverage` runs the same tests with the enforced coverage floor.
|
||||||
|
- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
|
||||||
|
|
||||||
## build-extension.cjs
|
## build-extension.cjs
|
||||||
|
|
||||||
@@ -59,9 +63,9 @@ npm run verify
|
|||||||
|
|
||||||
It currently runs:
|
It currently runs:
|
||||||
|
|
||||||
- Vitest unit tests.
|
- Vitest unit tests with coverage thresholds for importable source modules.
|
||||||
- Server ops, route, WebSocket, and rate-limiter checks.
|
- Server route and WebSocket integration checks.
|
||||||
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder 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.
|
- JavaScript syntax checks for server and extension entry points.
|
||||||
- Extension and website locale coverage checks.
|
- Extension and website locale coverage checks.
|
||||||
- ESLint.
|
- ESLint.
|
||||||
@@ -72,19 +76,43 @@ It currently runs:
|
|||||||
|
|
||||||
| Script | Purpose |
|
| 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-routes.mjs` | HTTP health routes, caching, and admin metrics access |
|
||||||
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
|
| `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-audio-settings.mjs` | Audio settings defaults and normalization |
|
||||||
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
|
| `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-content-video-finder.cjs` | Content-script video selection helpers |
|
||||||
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
|
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
|
||||||
| `test-website-locales.mjs` | Website 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
|
## Do Not Break
|
||||||
|
|
||||||
- Keep scripts runnable from the repository root.
|
- Keep scripts runnable from the repository root.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
])
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
|
||||||
@@ -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');
|
|
||||||
@@ -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');
|
|
||||||
@@ -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');
|
|
||||||
@@ -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');
|
|
||||||
@@ -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');
|
|
||||||
@@ -67,6 +67,25 @@ try {
|
|||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
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 ---
|
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
|
||||||
const capClient = await c();
|
const capClient = await c();
|
||||||
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
|
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
|
||||||
@@ -80,6 +99,27 @@ try {
|
|||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
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 ---
|
// --- Encrypted chat is a live-only canonical relay ---
|
||||||
const chatRoom = 'chat-'+Date.now();
|
const chatRoom = 'chat-'+Date.now();
|
||||||
const chat1 = await c(), chat2 = await c();
|
const chat1 = await c(), chat2 = await c();
|
||||||
|
|||||||
@@ -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');
|
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -7,22 +7,16 @@ import path from 'node:path';
|
|||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
['vitest unit tests', 'npm', ['run', 'test:unit']],
|
['coverage source inventory', 'node', ['scripts/check-coverage-inventory.mjs']],
|
||||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
|
||||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||||
}],
|
}],
|
||||||
['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']],
|
['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']],
|
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
['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']],
|
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||||
['chat settings', 'node', ['scripts/test-chat-settings.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 index', 'node', ['-c', 'server/index.js']],
|
||||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||||
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
|
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
|
||||||
|
|||||||
+3
-1
@@ -87,7 +87,9 @@ The server is covered by the root verification suite. From the repository root,
|
|||||||
npm run verify
|
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
|
## Security
|
||||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||||
|
|||||||
+24
-6
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
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 { createChatEnvelope } from './chat.js';
|
||||||
import {
|
import {
|
||||||
buildHealthPayload,
|
buildHealthPayload,
|
||||||
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||||
const roomCleanupInterval = setInterval(() => {
|
export function cleanupInactiveRooms(now = Date.now()) {
|
||||||
const now = Date.now();
|
|
||||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||||
|
|
||||||
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
|
|||||||
}
|
}
|
||||||
for (const sid of staleSids) {
|
for (const sid of staleSids) {
|
||||||
const deadSocket = io.sockets?.sockets?.get(sid);
|
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)}***`);
|
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||||
try {
|
try {
|
||||||
removePeerFromRoom(sid, roomId, 'reaper');
|
removePeerFromRoom(sid, roomId, 'reaper');
|
||||||
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
|
|||||||
// 2. Prune empty or inactive rooms
|
// 2. Prune empty or inactive rooms
|
||||||
const currentRoom = rooms.get(roomId);
|
const currentRoom = rooms.get(roomId);
|
||||||
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
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);
|
rooms.delete(roomId);
|
||||||
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
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) {
|
export function startServer(port = PORT, host) {
|
||||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||||
|
|||||||
@@ -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
@@ -1,28 +1,55 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
checkAdminMetricsAuthRate,
|
||||||
|
checkAuthRate,
|
||||||
|
checkConnectionRate,
|
||||||
|
checkEventRate,
|
||||||
|
checkHealthRate,
|
||||||
checkLeaveRoomRate,
|
checkLeaveRoomRate,
|
||||||
checkChatMessageRate,
|
checkChatMessageRate,
|
||||||
|
CONNECTION_RATE_LIMIT,
|
||||||
|
EVENT_RATE_LIMIT,
|
||||||
CHAT_MESSAGE_RATE_LIMIT,
|
CHAT_MESSAGE_RATE_LIMIT,
|
||||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||||
chatMessageCounts,
|
chatMessageCounts,
|
||||||
|
connectionCounts,
|
||||||
|
eventCounts,
|
||||||
|
healthCounts,
|
||||||
|
adminMetricsAuthCounts,
|
||||||
|
roomListCooldowns,
|
||||||
|
failedAuthAttempts,
|
||||||
LEAVE_ROOM_RATE_LIMIT,
|
LEAVE_ROOM_RATE_LIMIT,
|
||||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||||
rateLimitDenied,
|
rateLimitDenied,
|
||||||
leaveRoomCounts,
|
leaveRoomCounts,
|
||||||
clearRateLimitMaps
|
clearRateLimitMaps,
|
||||||
|
recordAuthFailure,
|
||||||
|
startRateLimitCleanup,
|
||||||
|
stopRateLimitCleanup
|
||||||
} from './rate-limiter.js';
|
} 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', () => {
|
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||||
const testSocketId = 'test-socket-123';
|
const testSocketId = 'test-socket-123';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearRateLimitMaps();
|
resetRateLimits();
|
||||||
rateLimitDenied.leaveRoom = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(resetRateLimits);
|
||||||
clearRateLimitMaps();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow LEAVE_ROOM within limit', () => {
|
it('should allow LEAVE_ROOM within limit', () => {
|
||||||
// Test within the rate limit
|
// Test within the rate limit
|
||||||
@@ -98,7 +125,7 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
|||||||
checkLeaveRoomRate(testSocketId);
|
checkLeaveRoomRate(testSocketId);
|
||||||
expect(leaveRoomCounts.size).toBe(1);
|
expect(leaveRoomCounts.size).toBe(1);
|
||||||
|
|
||||||
clearRateLimitMaps();
|
resetRateLimits();
|
||||||
expect(leaveRoomCounts.size).toBe(0);
|
expect(leaveRoomCounts.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -106,12 +133,9 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
|||||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||||
const socketId = 'chat-socket';
|
const socketId = 'chat-socket';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(resetRateLimits);
|
||||||
clearRateLimitMaps();
|
|
||||||
rateLimitDenied.chatMessages = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => clearRateLimitMaps());
|
afterEach(resetRateLimits);
|
||||||
|
|
||||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
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', () => {
|
describe('Rate Limit Constants', () => {
|
||||||
it('should have correct rate limit values', () => {
|
it('should have correct rate limit values', () => {
|
||||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||||
|
|||||||
@@ -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
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = "1.0.0";
|
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_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.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
|
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).
|
// Room control modes (Host Control Mode feature).
|
||||||
// NOTE: content.js does not import this module — it uses the string literals
|
// NOTE: content.js does not import this module — it uses the string literals
|
||||||
// 'everyone' / 'host-only' directly. Keep these values in sync there.
|
// 'everyone' / 'host-only' directly. Keep these values in sync there.
|
||||||
|
|||||||
@@ -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('👤');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,9 @@ enough to control it.
|
|||||||
npm run test:e2e:install # once, downloads the browsers
|
npm run test:e2e:install # once, downloads the browsers
|
||||||
npm run build:extension # extension.spec.mjs loads dist/chrome
|
npm run build:extension # extension.spec.mjs loads dist/chrome
|
||||||
npm run test:e2e
|
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
|
## Layout
|
||||||
@@ -16,11 +19,19 @@ npm run test:e2e
|
|||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages |
|
| `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages |
|
||||||
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
|
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
|
||||||
|
| `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery |
|
||||||
|
| `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup |
|
||||||
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
|
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
|
||||||
| `fixtures/pages/` | One page per scenario |
|
| `fixtures/pages/` | One page per scenario |
|
||||||
| `fixtures/media/` | Small generated clips (see below) |
|
| `fixtures/media/` | Small generated clips (see below) |
|
||||||
| `helpers/content-source.mjs` | Lifts the real finder out of `extension/content.js` |
|
| `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
|
## Two rules worth keeping
|
||||||
|
|
||||||
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
|
**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 |
|
| `late-frame.html` | Player frame attached after the page settled |
|
||||||
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
|
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
|
||||||
| `muted-player.html` | Mute must not disqualify the only player |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `background-loop.html` | Silent looping hero must lose despite being the largest |
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
|
|||||||
).toBeGreaterThan(5);
|
).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
|
test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
|
||||||
const first = `${baseURL}/pages/iframe-player.html`;
|
const first = `${baseURL}/pages/iframe-player.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(first);
|
await page.goto(first);
|
||||||
@@ -286,7 +286,7 @@ test('reinjects after the target tab navigates', async ({ context, extensionId,
|
|||||||
).toBe('true');
|
).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
|
test('@race re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/reloading-frame.html`;
|
const url = `${baseURL}/pages/reloading-frame.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -313,7 +313,7 @@ test('re-attaches after the player frame swaps its document', async ({ context,
|
|||||||
).toBe('true');
|
).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
|
test('@race re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/nested-frame.html`;
|
const url = `${baseURL}/pages/nested-frame.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -342,7 +342,7 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
|
|||||||
).toBe('true');
|
).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
|
test('@race moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/player-css-switch.html`;
|
const url = `${baseURL}/pages/player-css-switch.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -451,7 +451,7 @@ test('targets a visible nested cross-origin player and keeps top-page debug cont
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
|
test('@race re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/cross-origin-switching.html`;
|
const url = `${baseURL}/pages/cross-origin-switching.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -476,7 +476,7 @@ test('re-elects the visible cross-origin player after an iframe switch', async (
|
|||||||
expect(await first.locator('video').evaluate(video => video.paused)).toBe(true);
|
expect(await first.locator('video').evaluate(video => video.paused)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
|
test('@race immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/cross-origin-switching.html`;
|
const url = `${baseURL}/pages/cross-origin-switching.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -530,7 +530,7 @@ test('keeps commands flowing during continuous player-frame geometry changes', a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
|
test('@race deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
|
||||||
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
|
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
|
||||||
const secondUrl = `${baseURL}/pages/simple-player.html`;
|
const secondUrl = `${baseURL}/pages/simple-player.html`;
|
||||||
const firstPage = await context.newPage();
|
const firstPage = await context.newPage();
|
||||||
@@ -580,7 +580,7 @@ test('re-attaches after a selected cross-origin frame navigates', async ({ conte
|
|||||||
expect(state).toMatchObject({ found: true, inIframe: true });
|
expect(state).toMatchObject({ found: true, inIframe: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
|
test('@race discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/cross-origin-late.html`;
|
const url = `${baseURL}/pages/cross-origin-late.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -668,7 +668,7 @@ test('selects the visible anime player nested behind a same-origin wrapper', asy
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
|
test('@race selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
|
||||||
// The live case: at selection time the page has no video anywhere, because
|
// The live case: at selection time the page has no video anywhere, because
|
||||||
// the host only builds the player when the viewer presses play.
|
// the host only builds the player when the viewer presses play.
|
||||||
const url = `${baseURL}/pages/yummy-deferred-player.html`;
|
const url = `${baseURL}/pages/yummy-deferred-player.html`;
|
||||||
@@ -737,7 +737,7 @@ test('polling video state on a page with no video does not restart the target',
|
|||||||
.toBe('true');
|
.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);
|
test.setTimeout(90000);
|
||||||
// Live ad churn wakes the media-frame monitor several times a second. Each
|
// Live ad churn wakes the media-frame monitor several times a second. Each
|
||||||
// wake used to schedule a trailing refresh that rebuilt the target
|
// 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 });
|
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
|
test('@race recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
|
||||||
test.setTimeout(90000);
|
test.setTimeout(90000);
|
||||||
// Kodik rebuilds its player frame on quality and part changes, which kills
|
// Kodik rebuilds its player frame on quality and part changes, which kills
|
||||||
// the documentId the election is pinned to. The election has to be given up,
|
// the documentId the election is pinned to. The election has to be given up,
|
||||||
|
|||||||
@@ -7,30 +7,83 @@ import { test as base, chromium } from '@playwright/test';
|
|||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
export const extensionPath = path.join(repoRoot, 'dist/chrome');
|
export const extensionPath = path.join(repoRoot, 'dist/chrome');
|
||||||
|
|
||||||
|
function isLocalTestUrl(rawUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) return true;
|
||||||
|
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchExtensionContext() {
|
||||||
|
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
|
||||||
|
throw new Error('dist/chrome is missing. Run: npm run build:extension');
|
||||||
|
}
|
||||||
|
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
channel: 'chromium',
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
`--disable-extensions-except=${extensionPath}`,
|
||||||
|
`--load-extension=${extensionPath}`,
|
||||||
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
|
'--host-resolver-rules=MAP * 0.0.0.0, EXCLUDE localhost, EXCLUDE 127.0.0.1'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await context.route('**/*', route => {
|
||||||
|
if (isLocalTestUrl(route.request().url())) return route.continue();
|
||||||
|
return route.abort('blockedbyclient');
|
||||||
|
});
|
||||||
|
if (typeof context.routeWebSocket === 'function') {
|
||||||
|
await context.routeWebSocket(/.*/u, webSocketRoute => {
|
||||||
|
if (isLocalTestUrl(webSocketRoute.url())) {
|
||||||
|
webSocketRoute.connectToServer();
|
||||||
|
} else {
|
||||||
|
webSocketRoute.close({ code: 1008, reason: 'External network blocked by E2E harness' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let [worker] = context.serviceWorkers();
|
||||||
|
if (!worker) worker = await context.waitForEvent('serviceworker');
|
||||||
|
const extensionId = worker.url().split('/')[2];
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
extensionId,
|
||||||
|
async close() {
|
||||||
|
try {
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (context) await context.close().catch(() => {});
|
||||||
|
if (fs.existsSync(userDataDir)) {
|
||||||
|
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A browser with the packed extension loaded, plus its extension id. Each test
|
* A browser with the packed extension loaded, plus its extension id. Each test
|
||||||
* gets a throwaway profile so storage from one test cannot leak into the next.
|
* gets a throwaway profile so storage from one test cannot leak into the next.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend({
|
export const test = base.extend({
|
||||||
context: async ({}, use) => {
|
context: async ({}, use) => {
|
||||||
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
|
// The headless shell does not run MV3 service workers; the full
|
||||||
throw new Error('dist/chrome is missing. Run: npm run build:extension');
|
// Chromium build in new headless mode does.
|
||||||
|
const launched = await launchExtensionContext();
|
||||||
|
try {
|
||||||
|
await use(launched.context);
|
||||||
|
} finally {
|
||||||
|
await launched.close();
|
||||||
}
|
}
|
||||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
|
|
||||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
||||||
// The headless shell does not run MV3 service workers; the full
|
|
||||||
// Chromium build in new headless mode does.
|
|
||||||
channel: 'chromium',
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
`--disable-extensions-except=${extensionPath}`,
|
|
||||||
`--load-extension=${extensionPath}`,
|
|
||||||
'--autoplay-policy=no-user-gesture-required'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
await use(context);
|
|
||||||
await context.close();
|
|
||||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
extensionId: async ({ context }, use) => {
|
extensionId: async ({ context }, use) => {
|
||||||
let [worker] = context.serviceWorkers();
|
let [worker] = context.serviceWorkers();
|
||||||
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
|
|||||||
export async function writeStorage(page, values) {
|
export async function writeStorage(page, values) {
|
||||||
return page.evaluate(v => chrome.storage.local.set(v), values);
|
return page.evaluate(v => chrome.storage.local.set(v), values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function terminateServiceWorker(context, extensionId) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
|
||||||
|
session = await context.newCDPSession(page);
|
||||||
|
const { targetInfos } = await session.send('Target.getTargets');
|
||||||
|
const worker = targetInfos.find(target => target.type === 'service_worker'
|
||||||
|
&& target.url.startsWith(`chrome-extension://${extensionId}/`));
|
||||||
|
if (!worker) throw new Error(`service worker target missing for ${extensionId}`);
|
||||||
|
await session.send('Target.closeTarget', { targetId: worker.targetId });
|
||||||
|
} finally {
|
||||||
|
if (session) await session.detach().catch(() => {});
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('')}`);
|
||||||
|
}
|
||||||
@@ -12,19 +12,41 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: process.env.CI ? 'list' : [['list']],
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
use: {
|
use: {
|
||||||
baseURL: `http://localhost:${PORT}`,
|
baseURL: `http://localhost:${PORT}`,
|
||||||
trace: 'retain-on-failure',
|
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']
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
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: {
|
webServer: {
|
||||||
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
|
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
|
||||||
url: `http://localhost:${PORT}/pages/simple-player.html`,
|
url: `http://localhost:${PORT}/pages/simple-player.html`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { VITEST_COVERAGE_INCLUDE } from './scripts/coverage-plan.mjs';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -10,17 +11,50 @@ export default defineConfig({
|
|||||||
'shared/**/*.test.js',
|
'shared/**/*.test.js',
|
||||||
'shared/**/*.test.mjs',
|
'shared/**/*.test.mjs',
|
||||||
'extension/**/*.test.js',
|
'extension/**/*.test.js',
|
||||||
'extension/**/*.test.mjs'
|
'extension/**/*.test.mjs',
|
||||||
|
'scripts/**/*.test.mjs'
|
||||||
],
|
],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'lcov'],
|
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: [
|
exclude: [
|
||||||
'**/node_modules/**',
|
'**/node_modules/**'
|
||||||
'**/scripts/**',
|
],
|
||||||
'**/extension/**'
|
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
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
|
|||||||
|
|
||||||
## Technical information
|
## Technical information
|
||||||
|
|
||||||
- Current website release: 3.1.4
|
- Current website release: 3.1.5
|
||||||
- License: MIT
|
- License: MIT
|
||||||
- Extension runtime: dependency-free browser extension code
|
- Extension runtime: dependency-free browser extension code
|
||||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
"priceCurrency": "EUR"
|
"priceCurrency": "EUR"
|
||||||
},
|
},
|
||||||
"description": "{{SCHEMA_APP_DESC}}",
|
"description": "{{SCHEMA_APP_DESC}}",
|
||||||
"softwareVersion": "3.1.4",
|
"softwareVersion": "3.1.5",
|
||||||
"license": "https://opensource.org/licenses/MIT",
|
"license": "https://opensource.org/licenses/MIT",
|
||||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"date": "2026-08-21T10:33:30Z"
|
"date": "2026-08-24T21:56:28Z"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user