diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8383631..c2d53ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,36 @@ jobs: - name: Run verification suite run: npm run verify + node20: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up lowest supported Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: | + package-lock.json + server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Run unit and relay integration gates + run: | + npm run test:coverage + node scripts/test-server-routes.mjs + node scripts/test-server-ws.mjs + env: + ADMIN_METRICS_TOKEN: verify-admin-token-with-more-than-32-chars + e2e: # Kept separate from `verify`: this job needs a downloaded browser, so a # failure here should read as "the browser flow broke", not as a broken @@ -66,12 +96,27 @@ jobs: - name: Install root dependencies run: npm ci - - name: Install Playwright Chromium - run: npx playwright install --with-deps chromium chromium-headless-shell + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit # The extension specs load dist/chrome, so the artifact has to exist. - name: Build the extension run: npm run build:extension - - name: Run extension E2E smoke tests + - name: Run cross-browser detection and extension E2E tests run: npm run test:e2e + + - name: Upload browser failure diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-failure-diagnostics + path: | + test-results/ + playwright-report/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/race-tests.yml b/.github/workflows/race-tests.yml new file mode 100644 index 0000000..8f14849 --- /dev/null +++ b/.github/workflows/race-tests.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7014be..b627534 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,139 @@ on: tags: - 'v*' -# A release run must never be interrupted (it commits back to main and publishes -# artifacts). Only dedupe accidental re-pushes of the same tag. concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: + preflight: + runs-on: ubuntu-latest + permissions: + contents: read + checks: read + outputs: + version: ${{ steps.release-ref.outputs.version }} + tag-commit: ${{ steps.release-ref.outputs.tag_commit }} + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: | + package-lock.json + server/package-lock.json + + - name: Validate annotated tag, main commit, and required checks + id: release-ref + run: node scripts/release-preflight.mjs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Run complete release verification + run: npm run verify + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit + + - name: Run browser E2E suite + run: npm run test:e2e + + - name: Build relay container without publishing + run: docker build --file server/Dockerfile --tag koalasync-release-preflight . + + - name: Smoke-test relay container + run: | + CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-preflight-salt-with-more-than-thirty-two-chars koalasync-release-preflight) + trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT + HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://') + for attempt in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs "$CONTAINER_ID" + exit 1 + + release-extension-draft: + needs: preflight + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies and build release artifacts + run: | + npm ci + npm run build:extension + node website/build.cjs + + - name: Validate Firefox package + run: npx addons-linter --warnings-as-errors dist/koalasync-firefox.zip + + - name: Generate extension checksums + working-directory: dist + run: sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS + + - name: Validate release assets before publication + run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --asset-dir dist --skip-attestation + + - name: Attest extension archives + uses: actions/attest@v4 + with: + subject-path: dist/koalasync-*.zip + + - name: Create draft GitHub release + uses: softprops/action-gh-release@v3 + with: + files: | + dist/koalasync-chrome.zip + dist/koalasync-firefox.zip + dist/SHA256SUMS + name: Release ${{ github.ref_name }} + generate_release_notes: true + draft: true + prerelease: false + + - name: Verify draft extension release + run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload website artifacts + uses: actions/upload-artifact@v7 + with: + name: website-www + path: website/www/ + if-no-files-found: error + release-server: + needs: [preflight, release-extension-draft] runs-on: ubuntu-latest permissions: contents: read @@ -20,7 +145,7 @@ jobs: id-token: write attestations: write steps: - - name: Checkout code + - name: Checkout release tag uses: actions/checkout@v7 - name: Set up Docker Buildx @@ -52,126 +177,46 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # Reuse layers across releases to speed up the multi-arch build. cache-from: type=gha cache-to: type=gha,mode=max - - name: Generate artifact attestation + - name: Attest relay image uses: actions/attest@v4 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true - release-extension: + - name: Verify manifest, provenance, and running image + env: + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ghcr.io/${{ github.repository }} + SOURCE_DIGEST: ${{ needs.preflight.outputs.tag-commit }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker buildx imagetools inspect "$IMAGE@$DIGEST" --raw > /tmp/koalasync-manifest.json + node -e 'const m=require("/tmp/koalasync-manifest.json"); const p=new Set(m.manifests.map(x=>`${x.platform.os}/${x.platform.architecture}`)); for (const x of ["linux/amd64","linux/arm64"]) if(!p.has(x)) throw new Error(`missing platform ${x}`)' + gh attestation verify "oci://$IMAGE@$DIGEST" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" --source-ref "$GITHUB_REF" --source-digest "$SOURCE_DIGEST" --deny-self-hosted-runners + docker pull --platform linux/amd64 "$IMAGE@$DIGEST" + CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-smoke-salt-with-more-than-thirty-two-chars "$IMAGE@$DIGEST") + trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT + HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://') + for attempt in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs "$CONTAINER_ID" + exit 1 + + finalize-release: + needs: [preflight, release-extension-draft, release-server] runs-on: ubuntu-latest permissions: contents: write - id-token: write - attestations: write steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: 'npm' - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - - name: Inject version into source files - run: | - VERSION=${{ steps.version.outputs.VERSION }} - DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..." - - # 1. extension/manifest.base.json - jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json - echo " ✓ manifest.base.json -> $VERSION" - - # 2. shared/constants.js — APP_VERSION - sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js - echo " ✓ shared/constants.js -> $VERSION" - - # 3. package.json - jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json - echo " ✓ package.json -> $VERSION" - - # 4. package-lock.json root package metadata - jq --arg v "$VERSION" '.version = $v | .packages[""].version = $v' package-lock.json > tmp.json && mv tmp.json package-lock.json - echo " ✓ package-lock.json -> $VERSION" - - # 5. website/version.json - jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json - echo " ✓ website/version.json -> version $VERSION, date $DATE" - - # 6. website/template.html — SoftwareApplication schema - sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html - echo " ✓ website/template.html -> softwareVersion $VERSION" - - # 7. website/llms.txt — machine-readable release metadata - sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt - echo " ✓ website/llms.txt -> $VERSION" - - # 8. README.md — version badge & banner - sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md - sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md - echo " ✓ README.md -> v$VERSION" - - echo "Version injection complete." - - - name: Commit and push version updates back to main - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md - git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit" - git push origin HEAD:main + - name: Publish verified GitHub release + run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Extensions - run: | - npm ci - npm run build:extension - - - name: Generate extension checksums - run: | - cd dist - sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS - cat SHA256SUMS - - - name: Generate artifact attestation for extensions - uses: actions/attest@v4 - with: - subject-path: dist/koalasync-*.zip - - - name: Build Website - run: node website/build.cjs - - - name: Upload Website Artifacts - uses: actions/upload-artifact@v7 - with: - name: website-www - path: website/www/ - if-no-files-found: error - - - name: Create GitHub Release - uses: softprops/action-gh-release@v3 - with: - files: | - dist/koalasync-chrome.zip - dist/koalasync-firefox.zip - dist/SHA256SUMS - name: Release ${{ github.ref_name }} - generate_release_notes: true - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abba016..d3943c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co ### Prerequisites -- **Node.js** v20.9+ +- **Node.js** v20.19+ - **Docker** (for local relay server testing) ### Quick Start @@ -96,7 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share ## Version Numbers > [!CAUTION] -> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts. +> **Never edit release versions independently.** Release maintainers run +> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the +> resulting version changes through a CI-green pull request before tagging. --- diff --git a/README.md b/README.md index d4b449a..fcd8fa1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Release Status - GitHub release + GitHub release License Firefox Add-on Chrome Extension diff --git a/docs/AI_INIT.md b/docs/AI_INIT.md index 7fddb36..b1a0ae2 100644 --- a/docs/AI_INIT.md +++ b/docs/AI_INIT.md @@ -118,17 +118,23 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r > [!CAUTION] > **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.** > -> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures. +> **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`. > - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output. 1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file: - **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check. - **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check. -2. Commit all verified code changes and push to `main`. -3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix. +2. Commit the prepared version and release-note changes on a branch, push it, + open a pull request, and wait for required `verify`, `node20`, and `e2e` + checks. Direct pushes to `main` are not part of the release process. +3. After the PR is merged, update local `main` and create an annotated exact + SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as + `origin/main`. - **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds. - **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed. -4. The CI will extract the version from the tag (e.g., `v1.4.0` → `1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release. -5. Verify the release builds on GitHub Actions. +4. The release workflow validates the unchanged tagged source, creates a draft + release, publishes and verifies the relay image, and makes the release public + only after every gate succeeds. +5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke. ### 🚫 Force Push Policy > [!CAUTION] diff --git a/docs/SYNC_GUIDE.md b/docs/SYNC_GUIDE.md index df0f69b..6fec6ac 100644 --- a/docs/SYNC_GUIDE.md +++ b/docs/SYNC_GUIDE.md @@ -35,7 +35,10 @@ The build script performs the following actions: The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake. - The version is defined in `shared/constants.js`. - If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error. -- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates. +- Never edit release versions independently. Run + `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch; + the release tag is accepted only after that change reaches `main` with all CI + checks passing. > [!CAUTION] > **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script. diff --git a/docs/devops.md b/docs/devops.md index a9d08e0..6cf702b 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -4,30 +4,32 @@ This document describes the deployment and release process for KoalaSync. ## Tag-Based Releases -KoalaSync uses a fully automated release pipeline triggered by Git tags. +KoalaSync uses a gated release pipeline triggered by immutable Git tags. > [!IMPORTANT] -> **DO NOT** manually bump the version numbers in any files (such as `package.json`, `manifest.base.json`, `shared/constants.js`, etc.) before creating a release. -> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline. +> **DO NOT** edit individual version files or tag an unmerged branch. Run +> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated +> source changes, and merge them through a pull request with successful CI. ### How it Works -When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered. The workflow performs the following actions: +When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub +Actions workflow performs these ordered gates: -1. **Extracts the version** from the tag (e.g., `2.5.1` from `v2.5.1`). -2. **Injects the version** automatically into the following files: - - `extension/manifest.base.json` - - `shared/constants.js` (updates `APP_VERSION`) - - `package.json` - - `package-lock.json` (root package metadata) - - `website/version.json` - - `website/template.html` (updates `softwareVersion` schema) - - `README.md` (updates badge and announcement banner) - - `website/sitemap.xml` (updates `lastmod` dates) -3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`. -4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations. -5. **Builds the website** and uploads website artifacts. -6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`). +1. Confirms that the tag is annotated, points exactly at current `origin/main`, + and matches every committed version source. +2. Requires successful `verify`, `node20`, and `e2e` checks for that commit. +3. Re-runs release verification, cross-browser E2E, and an unpublished relay + container smoke test. +4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output, + website output, archive parity, and manifests. +5. Creates an attested **draft** GitHub Release. +6. Publishes the multi-architecture relay image, verifies both platforms, + attestation identity, digest, tag source, and a running health check. +7. Makes the GitHub Release public only after every preceding gate succeeds. + +The release workflow never writes to `main` and never derives shell code from a +tag. Version changes must pass normal branch protection first. --- @@ -35,18 +37,29 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea To release a new version (e.g., `v2.5.1`), follow these steps: -1. Make sure your local repository is synced on `main`: +1. Create a release-preparation branch from current `main` and update every + version source atomically: ```bash git checkout main git pull origin main + git checkout -b release/v2.5.1 + npm run prepare:release -- 2.5.1 + npm run verify ``` -2. Create a local Git tag: +2. Commit the release notes and prepared version changes, open a pull request, + and wait for required `verify`, `node20`, and `e2e` checks. +3. After the PR is merged, fast-forward local `main` and create an **annotated** + tag on that exact commit: ```bash - git tag v2.5.1 + git checkout main + git pull --ff-only origin main + git tag -a v2.5.1 -m "Release v2.5.1" ``` -3. Push the tag to GitHub: +4. Verify the tag target, then push it once: ```bash + test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)" git push origin v2.5.1 ``` -The release pipeline will take care of the rest! You can monitor the progress under the **Actions** tab of the GitHub repository. +Never reuse or move a published tag. Monitor every release job and verify both +the public GitHub assets and GHCR digest before calling the release complete. diff --git a/extension/README.md b/extension/README.md index 1e0412d..c790333 100644 --- a/extension/README.md +++ b/extension/README.md @@ -67,8 +67,7 @@ Useful focused checks from the repository root: node -c extension/background.js node -c extension/content.js node -c extension/popup.js -node scripts/test-episode-utils.mjs -node scripts/test-title-privacy.mjs +npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs node scripts/test-audio-settings.mjs node scripts/test-locales.cjs ``` diff --git a/extension/background.js b/extension/background.js index 656e049..c747bbe 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,4 +1,4 @@ -import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; +import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; import { generateUsername } from './shared/names.js'; import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; @@ -988,15 +988,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) return true; } -async function leaveRoomAfterIdleGrace(reason) { - if (!currentRoom) return; +async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) { + webJoinCoordinator.invalidate(); connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; - chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); + reconnectStartTime = null; completeForceSyncBeforeTargetChange(null); - emit(EVENTS.LEAVE_ROOM, { peerId }); - forceDisconnect(); + if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId }); + + // Stop room-specific polling before the content script itself is removed. + // Every terminal room exit must pass through the exact target identity while + // it is still available, regardless of who initiated the exit. + clearEpisodeLobbyState(); currentRoom = null; clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; @@ -1007,15 +1011,22 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); - if (currentTabId) await deactivateTargetTab(currentTabId); + if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget()); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); roomIdleSince = null; lastContentHeartbeatAt = null; - clearEpisodeLobbyState(); await clearPendingTarget(); + + isForceSyncInitiator = false; + forceSyncAcks.clear(); + expectedAcksCount = 0; + if (forceSyncTimeout) { + clearTimeout(forceSyncTimeout); + forceSyncTimeout = null; + } await chrome.storage.session.set({ currentRoom: null, chatActivityTimeline: [], @@ -1026,17 +1037,30 @@ async function leaveRoomAfterIdleGrace(reason) { currentTargetHasVideo: false, roomIdleSince: null, lastContentHeartbeatAt: null, + isForceSyncInitiator: false, + forceSyncAcks: [], + forceSyncDeadline: null, + expectedAcksCount: 0, episodeLobby: null, - hcmDesynced: false + hcmDesynced: false, + reconnectFailed: false, + reconnectAttempts: 0, + reconnectStartTime: null }).catch(() => {}); chatSecretGuard = ''; invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); - addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); + forceDisconnect(); + addLog(reason, 'info'); updateBadgeStatus(); } +async function leaveRoomAfterIdleGrace(reason) { + if (!currentRoom) return; + await endRoomSession({ notifyServer: true, reason }); +} + async function connect() { if (isConnecting) return; isConnecting = true; @@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) { } case EVENTS.ERROR: isConnecting = false; + const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED + || data.code === ERROR_CODES.PEER_TIMED_OUT + || data.message === 'Room closed' + || data.message === 'Removed from room after inactivity'; + if (currentRoom && terminalRoomError) { + await endRoomSession({ reason: `Room session ended: ${data.message}` }); + } // If we get a server error before successfully joining a room, // clear persisted credentials as well, otherwise service-worker // restart would immediately retry the rejected room. @@ -3340,6 +3371,14 @@ async function selectedMediaTargetMoved(tabId) { } return false; } + // A disappearing ad frame can make the parent-visibility handshake + // inconclusive while still leaving one hidden mirror as the only video + // candidate. Never rebuild toward an unconfirmed nested frame: its monitor + // or a later clean probe will announce it again if it is genuinely visible. + if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) { + refreshMediaFrameMonitors(tabId).catch(() => {}); + return false; + } if (currentTargetHasVideo !== true) return true; return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId) || (typeof resolved.documentId === 'string' @@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { - webJoinCoordinator.invalidate(); - completeForceSyncBeforeTargetChange(null); - connectIntent = false; - reconnectFailed = false; - reconnectAttempts = 0; - chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); - emit(EVENTS.LEAVE_ROOM, { peerId }); - currentRoom = null; - clearChatActivity(); - controlMode = CONTROL_MODES.EVERYONE; - hostPeerId = null; - controllers = []; - serverCapabilities = []; - hcmDesynced = false; - // Notify content.js/popup BEFORE currentTabId is cleared so they drop any - // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. - broadcastControlMode(); - if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget()); - invalidateTargetActivations(); - currentTabId = null; - currentTabTitle = null; - clearCurrentContentTarget(); - roomIdleSince = null; - lastContentHeartbeatAt = null; - - updateBadgeStatus(); - - isForceSyncInitiator = false; - forceSyncAcks.clear(); - expectedAcksCount = 0; - if (forceSyncTimeout) clearTimeout(forceSyncTimeout); - - // Cancel any active episode lobby - clearEpisodeLobbyState(); - await clearPendingTarget(); - - chrome.storage.session.set({ - currentRoom: null, - chatActivityTimeline: [], - currentTabId: null, - currentTabTitle: null, - currentTargetFrameId: 0, - currentTargetDocumentId: null, - currentTargetHasVideo: false, - roomIdleSince: null, - lastContentHeartbeatAt: null, - isForceSyncInitiator: false, - forceSyncAcks: [], - forceSyncDeadline: null, - episodeLobby: null, - expectedAcksCount: 0, - hcmDesynced: false - }); - chatSecretGuard = ''; - invalidateChatSession(); - chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); - addLog('Left Room', 'info'); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); - forceDisconnect(); + await endRoomSession({ notifyServer: true, reason: 'Left Room' }); sendResponse({ status: 'ok' }); } else if (message.type === 'CLEAR_LOGS') { logs = []; diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs index a2e205d..46caf3b 100644 --- a/extension/chat-crypto.test.mjs +++ b/extension/chat-crypto.test.mjs @@ -39,12 +39,15 @@ describe('chat crypto', () => { const secret = generateChatSecret(webcrypto); let deriveCalls = 0; let releaseDerive; + let markDeriveStarted; + const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; }); const delayedCrypto = { ...webcrypto, subtle: { importKey: (...args) => webcrypto.subtle.importKey(...args), deriveKey: async (...args) => { deriveCalls++; + markDeriveStarted(); await new Promise(resolve => { releaseDerive = resolve; }); return webcrypto.subtle.deriveKey(...args); } @@ -52,7 +55,7 @@ describe('chat crypto', () => { }; const first = deriveChatKey('ROOM-1', secret, delayedCrypto); const second = deriveChatKey('ROOM-1', secret, delayedCrypto); - await Promise.resolve(); + await deriveStarted; expect(deriveCalls).toBe(1); clearChatKeyCache(); releaseDerive(); diff --git a/extension/episode-utils.test.mjs b/extension/episode-utils.test.mjs new file mode 100644 index 0000000..32edd24 --- /dev/null +++ b/extension/episode-utils.test.mjs @@ -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); + }); +}); diff --git a/extension/host-access.test.mjs b/extension/host-access.test.mjs new file mode 100644 index 0000000..f3767b0 --- /dev/null +++ b/extension/host-access.test.mjs @@ -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"/); + }); +}); diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 960a184..126c82f 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -3,7 +3,7 @@ "default_locale": "en", "name": "__MSG_appName__", "short_name": "KoalaSync", - "version": "3.1.4", + "version": "3.1.5", "description": "__MSG_appDesc__", "permissions": [ "storage", diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index bab8780..cf403c8 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) { documentId, frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null, hasVideo: !!selected?.result?.bestVideo, + visibilityConfirmed: selected?.result?.parentFrameVisible === true, scriptTarget: documentId ? { tabId, documentIds: [documentId] } : (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] }) diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index 3695a5d..03a0570 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => { documentId: 'document-8', frameUrl: 'https://player-8.example/embed', hasVideo: true, + visibilityConfirmed: true, // Reported back so the caller can address these frames directly when // a later all-frames sweep is rejected wholesale. discoveredFrameIds: [0, 8], @@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => { documentId: null, frameUrl: null, hasVideo: false, + visibilityConfirmed: false, discoveredFrameIds: [0, 6], scriptTarget: { tabId: 42 } }); @@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => { documentId: null, frameUrl: null, hasVideo: false, + visibilityConfirmed: false, discoveredFrameIds: [0], scriptTarget: { tabId: 42 } }); diff --git a/extension/popup-layout.test.mjs b/extension/popup-layout.test.mjs new file mode 100644 index 0000000..aad9f1a --- /dev/null +++ b/extension/popup-layout.test.mjs @@ -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); + }); +}); diff --git a/extension/popup.html b/extension/popup.html index 8a8035e..a35fe54 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -180,11 +180,25 @@ color-scheme: light; } + /* Defensive popup boundary: descendants may be populated dynamically + after Chrome has measured the action popup. Keep their intrinsic + width from resizing the popup window while preserving normal vertical + layout and scrolling inside the established 360px surface. */ + html { + width: 360px; + min-width: 360px; + max-width: 360px; + overflow-x: hidden; + } + body { width: 360px; + max-width: 360px; margin: 0; padding: 18px; box-sizing: border-box; + contain: inline-size; + overflow-x: hidden; background: var(--bg); color: var(--text); font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; @@ -1413,16 +1427,16 @@ -

- - - - - +
+ + + + +
-
+
@@ -1510,7 +1524,7 @@
-
+