diff --git a/extension/popup.js b/extension/popup.js
index 5d81aa0..59fbad9 100644
--- a/extension/popup.js
+++ b/extension/popup.js
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
- elements.tabs.forEach(b => b.classList.remove('active'));
- elements.contents.forEach(c => c.classList.remove('active'));
+ elements.tabs.forEach(b => {
+ b.classList.remove('active');
+ b.setAttribute('aria-selected', 'false');
+ b.tabIndex = -1;
+ });
+ elements.contents.forEach(c => {
+ c.classList.remove('active');
+ c.setAttribute('aria-hidden', 'true');
+ });
btn.classList.add('active');
+ btn.setAttribute('aria-selected', 'true');
+ btn.tabIndex = 0;
const targetContent = document.getElementById(btn.dataset.tab);
targetContent.classList.add('active');
+ targetContent.removeAttribute('aria-hidden');
targetContent.classList.remove('tab-active-animate');
void targetContent.offsetWidth; // Force reflow to restart animation
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
chrome.storage.local.set({ activeTab: btn.dataset.tab });
});
+
+ btn.addEventListener('keydown', event => {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ const visibleTabs = [...elements.tabs].filter(tab => window.getComputedStyle(tab).display !== 'none');
+ const currentIndex = visibleTabs.indexOf(btn);
+ const nextIndex = event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? visibleTabs.length - 1
+ : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + visibleTabs.length) % visibleTabs.length;
+ event.preventDefault();
+ visibleTabs[nextIndex].focus();
+ visibleTabs[nextIndex].click();
+ });
});
function showToast(message, type = 'info', duration = 3000) {
diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs
index 1611219..9ff87a0 100644
--- a/extension/target-tab-lifecycle.test.mjs
+++ b/extension/target-tab-lifecycle.test.mjs
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
+const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
+const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
+ it('routes every terminal room exit through the full target unhook', () => {
+ const teardownStart = backgroundSource.indexOf('async function endRoomSession');
+ const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
+ const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
+ expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
+ expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
+ .toBeLessThan(teardownSource.indexOf('currentTabId = null'));
+ expect(teardownSource).toContain('await clearPendingTarget()');
+ expect(teardownSource).toContain('forceDisconnect()');
+
+ expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
+ expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
+ expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
+ expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
+ expect(backgroundSource).toContain("data.message === 'Room closed'");
+ expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
+ expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
+
+ expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
+ expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
+ expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
+ expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
+ expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
+ });
+
+ it('does not promote a nested media target without confirmed parent visibility', () => {
+ expect(backgroundSource).toContain(
+ 'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
+ );
+ });
+
it('removes monitors injected by a superseded cross-tab activation', () => {
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
diff --git a/extension/title-privacy.test.mjs b/extension/title-privacy.test.mjs
new file mode 100644
index 0000000..846434b
--- /dev/null
+++ b/extension/title-privacy.test.mjs
@@ -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'
+ });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 3c68566..a5f9d4f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"devDependencies": {
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
@@ -23,7 +23,7 @@
"vitest": "^4.1.10"
},
"engines": {
- "node": ">=20.9.0"
+ "node": ">=20.19.0"
}
},
"node_modules/@babel/code-frame": {
diff --git a/package.json b/package.json
index 9eaa1b0..5370a5e 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,26 @@
{
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"engines": {
- "node": ">=20.9.0"
+ "node": ">=20.19.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+ "prepare:release": "node scripts/prepare-release.mjs",
"subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify",
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
- "test:e2e:install": "playwright install chromium chromium-headless-shell",
+ "test:e2e:detection": "playwright test --config tests/e2e/playwright.config.mjs --project=detection-chromium --project=detection-firefox --project=detection-webkit",
+ "test:e2e:extension": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium",
+ "test:e2e:install": "playwright install chromium chromium-headless-shell firefox webkit",
+ "test:e2e:race": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium --grep @race --repeat-each=20",
+ "test:coverage": "vitest run --coverage",
"test:unit": "vitest run",
"verify": "node scripts/verify-release.mjs"
},
diff --git a/scripts/README.md b/scripts/README.md
index eee47bc..c32a8a7 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -9,12 +9,16 @@ npm run build:extension
npm run verify
npm run lint
npm run test:unit
+npm run test:coverage
+npm run prepare:release -- 3.1.5
```
- `npm run build:extension` runs `scripts/build-extension.cjs`.
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
- `npm run lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests.
+- `npm run test:coverage` runs the same tests with the enforced coverage floor.
+- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
## build-extension.cjs
@@ -59,9 +63,9 @@ npm run verify
It currently runs:
-- Vitest unit tests.
-- Server ops, route, WebSocket, and rate-limiter checks.
-- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
+- Vitest unit tests with coverage thresholds for importable source modules.
+- Server route and WebSocket integration checks.
+- Episode parser, title privacy, host access, blacklist, names, rate limiting, audio settings, popup cooldown, and content-video-finder checks.
- JavaScript syntax checks for server and extension entry points.
- Extension and website locale coverage checks.
- ESLint.
@@ -72,19 +76,43 @@ It currently runs:
| Script | Purpose |
|:---|:---|
-| `test-server-ops.mjs` | Health payload and admin metrics helpers |
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
-| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
-| `test-episode-utils.mjs` | Episode-title extraction and comparison |
-| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
-| `test-names.mjs` | Generated username format and coverage |
| `test-content-video-finder.cjs` | Content-script video selection helpers |
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
| `test-website-locales.mjs` | Website locale coverage |
+## Coverage Boundary
+
+`vitest.config.mjs` covers importable modules executed by Vitest and enforces
+both global and risk-specific per-module floors. Browser entry points
+(`background.js`, `content.js`, and `popup.js`) and server process startup are
+deliberately measured by extension E2E and integration tests instead of being
+reported as zero-coverage unit code.
+`scripts/check-coverage-inventory.mjs` additionally requires every JavaScript
+source file to be classified as V8-covered or assigned to a named external
+integration gate. New unclassified files fail `npm run verify`.
+
+## Published Release Verification
+
+Before publication, the release workflow validates the exact annotated SemVer
+tag, requires it to point at current `origin/main`, requires successful
+`verify`, `node20`, and `e2e` checks, and runs the complete gates again. It then
+creates a draft release, publishes and smoke-tests the relay image, and only
+afterwards makes the GitHub Release public. The published-asset gate runs:
+
+```bash
+node scripts/verify-published-release.mjs vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync
+```
+
+The verifier requires the exact three release assets, validates SHA-256 hashes,
+annotated-tag ancestry, Chrome/Firefox manifest versions and runtime injection,
+archive parity, unsafe/development-only paths, and GitHub attestations. For a
+local archive-only diagnosis, pass `--asset-dir PATH`; this deliberately skips
+GitHub inventory and attestation checks.
+
## Do Not Break
- Keep scripts runnable from the repository root.
diff --git a/scripts/check-coverage-inventory.mjs b/scripts/check-coverage-inventory.mjs
new file mode 100644
index 0000000..a5b463d
--- /dev/null
+++ b/scripts/check-coverage-inventory.mjs
@@ -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;
+ }
+}
diff --git a/scripts/coverage-plan.mjs b/scripts/coverage-plan.mjs
new file mode 100644
index 0000000..601dabd
--- /dev/null
+++ b/scripts/coverage-plan.mjs
@@ -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'
+ ])
+});
diff --git a/scripts/coverage-plan.test.mjs b/scripts/coverage-plan.test.mjs
new file mode 100644
index 0000000..06a3f6f
--- /dev/null
+++ b/scripts/coverage-plan.test.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');
+ });
+});
diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs
new file mode 100644
index 0000000..f7c2fc2
--- /dev/null
+++ b/scripts/prepare-release.mjs
@@ -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;
+ }
+}
diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs
new file mode 100644
index 0000000..8e1faf1
--- /dev/null
+++ b/scripts/prepare-release.test.mjs
@@ -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');
+ });
+});
diff --git a/scripts/release-artifact-checks.mjs b/scripts/release-artifact-checks.mjs
new file mode 100644
index 0000000..ab8f4a4
--- /dev/null
+++ b/scripts/release-artifact-checks.mjs
@@ -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 || ''}`);
+ 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 || ''} 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(', ') || ''}; Firefox only: ${firefoxOnly.join(', ') || ''}`);
+ }
+}
diff --git a/scripts/release-artifact-checks.test.mjs b/scripts/release-artifact-checks.test.mjs
new file mode 100644
index 0000000..8b5b3e7
--- /dev/null
+++ b/scripts/release-artifact-checks.test.mjs
@@ -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'
+ );
+ });
+});
diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs
new file mode 100644
index 0000000..da6dcaa
--- /dev/null
+++ b/scripts/release-preflight.mjs
@@ -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 || ''}`);
+ }
+ return repo;
+}
+
+export function validateVersionSnapshot(expectedVersion, snapshot) {
+ for (const [label, actualVersion] of Object.entries(snapshot)) {
+ if (actualVersion !== expectedVersion) {
+ throw new Error(`${label} version ${actualVersion || ''} 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;
+ }
+}
diff --git a/scripts/release-preflight.test.mjs b/scripts/release-preflight.test.mjs
new file mode 100644
index 0000000..52390f7
--- /dev/null
+++ b/scripts/release-preflight.test.mjs
@@ -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');
+ });
+});
diff --git a/scripts/test-blacklist-settings.mjs b/scripts/test-blacklist-settings.mjs
deleted file mode 100644
index 1ba7015..0000000
--- a/scripts/test-blacklist-settings.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-episode-utils.mjs b/scripts/test-episode-utils.mjs
deleted file mode 100644
index b65cb7c..0000000
--- a/scripts/test-episode-utils.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs
deleted file mode 100644
index 69308e5..0000000
--- a/scripts/test-host-access.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-names.mjs b/scripts/test-names.mjs
deleted file mode 100644
index 5611baf..0000000
--- a/scripts/test-names.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-rate-limiter.mjs b/scripts/test-rate-limiter.mjs
deleted file mode 100644
index 4ee5cb6..0000000
--- a/scripts/test-rate-limiter.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-server-ops.mjs b/scripts/test-server-ops.mjs
deleted file mode 100644
index 65cec5b..0000000
--- a/scripts/test-server-ops.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs
index 3b7a509..7881b6f 100644
--- a/scripts/test-server-ws.mjs
+++ b/scripts/test-server-ws.mjs
@@ -67,6 +67,25 @@ try {
close();
resetConnectionRate();
+ // --- Stale peer reaper: terminal timeout + clean rejoin ---
+ const staleClient = await c();
+ const staleRoomId = 'stale-'+Date.now();
+ await j(staleClient, staleRoomId, 'stale-peer');
+ staleClient._m.length = 0;
+ const staleRoom = mod.rooms.get(staleRoomId);
+ staleRoom.peerData.values().next().value.lastSeen = 1;
+ mod.cleanupInactiveRooms(Date.now());
+ const [staleEvent, staleData] = await a(staleClient);
+ assert.equal(staleEvent, 'error');
+ assert.equal(staleData.code, 'peer_timed_out');
+ assert.equal(staleData.message, 'Removed from room after inactivity');
+ assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
+ staleClient._m.length = 0;
+ await j(staleClient, staleRoomId, 'stale-peer');
+ assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
+ close();
+ resetConnectionRate();
+
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
const capClient = await c();
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
@@ -80,6 +99,27 @@ try {
close();
resetConnectionRate();
+ // --- Terminal room timeout: coded error + complete membership cleanup ---
+ const timeoutClient = await c();
+ const timeoutRoomId = 'timeout-'+Date.now();
+ await j(timeoutClient, timeoutRoomId, 'timeout-peer');
+ timeoutClient._m.length = 0;
+ mod.rooms.get(timeoutRoomId).lastActivity = 0;
+ mod.cleanupInactiveRooms(Date.now());
+ const [timeoutEvent, timeoutData] = await a(timeoutClient);
+ assert.equal(timeoutEvent, 'error');
+ assert.equal(timeoutData.code, 'room_closed');
+ assert.equal(timeoutData.message, 'Room closed');
+ assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
+ timeoutClient._m.length = 0;
+
+ // The same connected socket must be able to join that room again. This
+ // proves timeout cleanup removed its stale socketToRoom membership.
+ await j(timeoutClient, timeoutRoomId, 'timeout-peer');
+ assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
+ close();
+ resetConnectionRate();
+
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
diff --git a/scripts/test-title-privacy.mjs b/scripts/test-title-privacy.mjs
deleted file mode 100644
index 33dd0dc..0000000
--- a/scripts/test-title-privacy.mjs
+++ /dev/null
@@ -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');
diff --git a/scripts/verify-published-release.mjs b/scripts/verify-published-release.mjs
new file mode 100644
index 0000000..1237200
--- /dev/null
+++ b/scripts/verify-published-release.mjs
@@ -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 [--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;
+});
diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs
index db3d515..d0f2f1a 100644
--- a/scripts/verify-release.mjs
+++ b/scripts/verify-release.mjs
@@ -7,22 +7,16 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
- ['vitest unit tests', 'npm', ['run', 'test:unit']],
- ['server ops', 'node', ['scripts/test-server-ops.mjs']],
+ ['coverage source inventory', 'node', ['scripts/check-coverage-inventory.mjs']],
+ ['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
}],
- ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
- ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
- ['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
- ['names generator', 'node', ['scripts/test-names.mjs']],
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
- ['blacklist settings', 'node', ['scripts/test-blacklist-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
- ['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
diff --git a/server/README.md b/server/README.md
index 320db91..d515677 100644
--- a/server/README.md
+++ b/server/README.md
@@ -87,7 +87,9 @@ The server is covered by the root verification suite. From the repository root,
npm run verify
```
-For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
+For focused server checks, run `npm run test:unit` for `server/ops.test.mjs`
+and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
+`scripts/test-server-ws.mjs` for process-level integration coverage.
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
diff --git a/server/index.js b/server/index.js
index e98da03..fc5bd88 100644
--- a/server/index.js
+++ b/server/index.js
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
-import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
+import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
-const roomCleanupInterval = setInterval(() => {
- const now = Date.now();
+export function cleanupInactiveRooms(now = Date.now()) {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
}
for (const sid of staleSids) {
const deadSocket = io.sockets?.sockets?.get(sid);
- if (deadSocket) deadSocket.leave(roomId);
+ if (deadSocket) {
+ deadSocket.emit(EVENTS.ERROR, {
+ code: ERROR_CODES.PEER_TIMED_OUT,
+ message: 'Removed from room after inactivity'
+ });
+ deadSocket.leave(roomId);
+ }
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
try {
removePeerFromRoom(sid, roomId, 'reaper');
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
// 2. Prune empty or inactive rooms
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
- io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
+ io.to(roomId).emit(EVENTS.ERROR, {
+ code: ERROR_CODES.ROOM_CLOSED,
+ message: 'Room closed'
+ });
+ // A terminal room timeout is a real leave for every member. Clear
+ // the same socket/peer indexes as an explicit leave so a later join
+ // cannot be mistaken for the stale membership.
+ for (const sid of Array.from(currentRoom.peers)) {
+ const memberSocket = io.sockets?.sockets?.get(sid);
+ if (memberSocket) memberSocket.leave(roomId);
+ removePeerFromRoom(sid, roomId, 'room-timeout');
+ }
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
}
}
-}, 2 * 60 * 1000);
+}
+
+const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);
diff --git a/server/ops.test.mjs b/server/ops.test.mjs
new file mode 100644
index 0000000..b5d294b
--- /dev/null
+++ b/server/ops.test.mjs
@@ -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
+ }
+ }
+ });
+ });
+});
diff --git a/server/rate-limiter.test.mjs b/server/rate-limiter.test.mjs
index d5ac4bd..f06ee54 100644
--- a/server/rate-limiter.test.mjs
+++ b/server/rate-limiter.test.mjs
@@ -1,28 +1,55 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
+ checkAdminMetricsAuthRate,
+ checkAuthRate,
+ checkConnectionRate,
+ checkEventRate,
+ checkHealthRate,
checkLeaveRoomRate,
checkChatMessageRate,
+ CONNECTION_RATE_LIMIT,
+ EVENT_RATE_LIMIT,
CHAT_MESSAGE_RATE_LIMIT,
CHAT_MESSAGE_RATE_WINDOW_MS,
chatMessageCounts,
+ connectionCounts,
+ eventCounts,
+ healthCounts,
+ adminMetricsAuthCounts,
+ roomListCooldowns,
+ failedAuthAttempts,
LEAVE_ROOM_RATE_LIMIT,
LEAVE_ROOM_RATE_WINDOW_MS,
rateLimitDenied,
leaveRoomCounts,
- clearRateLimitMaps
+ clearRateLimitMaps,
+ recordAuthFailure,
+ startRateLimitCleanup,
+ stopRateLimitCleanup
} from './rate-limiter.js';
+function resetRateLimits() {
+ stopRateLimitCleanup();
+ clearRateLimitMaps();
+ Object.assign(rateLimitDenied, {
+ connections: 0,
+ events: 0,
+ health: 0,
+ adminMetricsAuth: 0,
+ roomList: 0,
+ leaveRoom: 0,
+ chatMessages: 0
+ });
+}
+
describe('LEAVE_ROOM Rate Limiter', () => {
const testSocketId = 'test-socket-123';
beforeEach(() => {
- clearRateLimitMaps();
- rateLimitDenied.leaveRoom = 0;
+ resetRateLimits();
});
- afterEach(() => {
- clearRateLimitMaps();
- });
+ afterEach(resetRateLimits);
it('should allow LEAVE_ROOM within limit', () => {
// Test within the rate limit
@@ -98,7 +125,7 @@ describe('LEAVE_ROOM Rate Limiter', () => {
checkLeaveRoomRate(testSocketId);
expect(leaveRoomCounts.size).toBe(1);
- clearRateLimitMaps();
+ resetRateLimits();
expect(leaveRoomCounts.size).toBe(0);
});
});
@@ -106,12 +133,9 @@ describe('LEAVE_ROOM Rate Limiter', () => {
describe('CHAT_MESSAGE Rate Limiter', () => {
const socketId = 'chat-socket';
- beforeEach(() => {
- clearRateLimitMaps();
- rateLimitDenied.chatMessages = 0;
- });
+ beforeEach(resetRateLimits);
- afterEach(() => clearRateLimitMaps());
+ afterEach(resetRateLimits);
it('allows ten messages per ten-second window and blocks the next', () => {
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
@@ -129,6 +153,90 @@ describe('CHAT_MESSAGE Rate Limiter', () => {
});
});
+describe('remaining relay rate limits', () => {
+ beforeEach(resetRateLimits);
+ afterEach(resetRateLimits);
+
+ it.each([
+ ['connection', checkConnectionRate, CONNECTION_RATE_LIMIT, 'ip-1', 'connections'],
+ ['event', checkEventRate, EVENT_RATE_LIMIT, 'socket-1', 'events'],
+ ['health', checkHealthRate, 10, 'ip-2', 'health'],
+ ['admin metrics auth', checkAdminMetricsAuthRate, 5, 'ip-3', 'adminMetricsAuth']
+ ])('enforces the %s window and increments its denial counter', (_label, check, limit, key, counter) => {
+ for (let attempt = 0; attempt < limit; attempt++) expect(check(key)).toBe(true);
+ expect(check(key)).toBe(false);
+ expect(rateLimitDenied[counter]).toBe(1);
+ expect(check(`${key}-other`)).toBe(true);
+ });
+
+ it('scopes failed authentication attempts to IP and room', () => {
+ for (let attempt = 0; attempt < 5; attempt++) recordAuthFailure('10.0.0.1', 'room-a');
+ expect(checkAuthRate('10.0.0.1', 'room-a')).toBe(false);
+ expect(checkAuthRate('10.0.0.1', 'room-b')).toBe(true);
+ expect(failedAuthAttempts.get('10.0.0.1:room-a')).toMatchObject({ count: 5 });
+ });
+
+ it('starts cleanup only once and can stop safely', () => {
+ const io = { sockets: { sockets: new Map() } };
+ expect(() => {
+ startRateLimitCleanup(io);
+ startRateLimitCleanup(io);
+ stopRateLimitCleanup();
+ stopRateLimitCleanup();
+ }).not.toThrow();
+ });
+
+ it('clears every rate-limit map', () => {
+ const maps = [
+ connectionCounts,
+ failedAuthAttempts,
+ eventCounts,
+ chatMessageCounts,
+ healthCounts,
+ adminMetricsAuthCounts,
+ roomListCooldowns,
+ leaveRoomCounts
+ ];
+ maps.forEach((map, index) => map.set(`key-${index}`, { count: 1 }));
+ clearRateLimitMaps();
+ maps.forEach(map => expect(map.size).toBe(0));
+ });
+
+ it('removes expired and disconnected entries in both cleanup intervals', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-08-21T10:00:00Z'));
+ const now = Date.now();
+ const sockets = new Map([['connected', {}]]);
+ connectionCounts.set('expired-ip', { count: 1, resetTime: now - 1 });
+ connectionCounts.set('live-ip', { count: 1, resetTime: now + 120000 });
+ eventCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
+ eventCounts.set('connected', { count: 1, resetTime: now + 120000 });
+ chatMessageCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
+ leaveRoomCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
+ healthCounts.set('expired-health', { count: 1, resetTime: now - 1 });
+ adminMetricsAuthCounts.set('expired-admin', { count: 1, resetTime: now - 1 });
+ roomListCooldowns.set('disconnected', now);
+ roomListCooldowns.set('connected', now);
+ failedAuthAttempts.set('expired-auth', { count: 1, lastAttempt: now - (16 * 60 * 1000) });
+ failedAuthAttempts.set('live-auth', { count: 1, lastAttempt: now });
+
+ startRateLimitCleanup({ sockets: { sockets } });
+ await vi.advanceTimersByTimeAsync(60000);
+ expect([...connectionCounts.keys()]).toEqual(['live-ip']);
+ expect([...eventCounts.keys()]).toEqual(['connected']);
+ expect(chatMessageCounts.size).toBe(0);
+ expect(leaveRoomCounts.size).toBe(0);
+ expect(healthCounts.size).toBe(0);
+ expect(adminMetricsAuthCounts.size).toBe(0);
+ expect([...roomListCooldowns.keys()]).toEqual(['connected']);
+
+ await vi.advanceTimersByTimeAsync(14 * 60 * 1000);
+ expect([...failedAuthAttempts.keys()]).toEqual(['live-auth']);
+ stopRateLimitCleanup();
+ vi.useRealTimers();
+ });
+});
+
describe('Rate Limit Constants', () => {
it('should have correct rate limit values', () => {
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
diff --git a/shared/blacklist.test.mjs b/shared/blacklist.test.mjs
new file mode 100644
index 0000000..9fec0fb
--- /dev/null
+++ b/shared/blacklist.test.mjs
@@ -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"/);
+ });
+});
diff --git a/shared/constants.js b/shared/constants.js
index 7cf8361..ee43007 100644
--- a/shared/constants.js
+++ b/shared/constants.js
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
-export const APP_VERSION = "3.1.4";
+export const APP_VERSION = "3.1.5";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -65,6 +65,14 @@ export const EVENTS = {
PONG: "pong" // server responds with same { t } for client RTT calculation
};
+// Stable server error identifiers. Clients must branch on these codes instead
+// of localized or user-facing message text whenever the error changes session
+// state.
+export const ERROR_CODES = {
+ ROOM_CLOSED: 'room_closed',
+ PEER_TIMED_OUT: 'peer_timed_out'
+};
+
// Room control modes (Host Control Mode feature).
// NOTE: content.js does not import this module — it uses the string literals
// 'everyone' / 'host-only' directly. Keep these values in sync there.
diff --git a/shared/names.test.mjs b/shared/names.test.mjs
new file mode 100644
index 0000000..0da8708
--- /dev/null
+++ b/shared/names.test.mjs
@@ -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('👤');
+ });
+});
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index a8dc951..09987b6 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -8,6 +8,9 @@ enough to control it.
npm run test:e2e:install # once, downloads the browsers
npm run build:extension # extension.spec.mjs loads dist/chrome
npm run test:e2e
+npm run test:e2e:detection # finder only: Chromium, Firefox, WebKit
+npm run test:e2e:extension # packed extension only: Chromium MV3
+npm run test:e2e:race # @race scenarios, repeated 20 times
```
## Layout
@@ -16,11 +19,19 @@ npm run test:e2e
| :--- | :--- |
| `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages |
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
+| `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery |
+| `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup |
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
| `fixtures/pages/` | One page per scenario |
| `fixtures/media/` | Small generated clips (see below) |
| `helpers/content-source.mjs` | Lifts the real finder out of `extension/content.js` |
+The detection fixtures run as three Playwright projects: Chromium, Firefox,
+and WebKit. Packed-extension tests remain Chromium-only because they exercise
+Chrome MV3 APIs and a persistent service-worker context. The scheduled
+`.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads
+traces/results on failure.
+
## Two rules worth keeping
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
@@ -45,6 +56,7 @@ reads as a broken fixture instead of a scoring regression.
| `late-frame.html` | Player frame attached after the page settled |
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
| `muted-player.html` | Mute must not disqualify the only player |
+| `display-contents-player.html` | A visible player survives a boxless `display: contents` wrapper |
| `hidden-preload.html` | A `display:none` preload still reports 1080p; it must lose |
| `ad-frame.html` | 1080p asset in a 300x250 ad slot must lose to the real player |
| `background-loop.html` | Silent looping hero must lose despite being the largest |
diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs
index 62e719e..94732b6 100644
--- a/tests/e2e/extension.spec.mjs
+++ b/tests/e2e/extension.spec.mjs
@@ -263,7 +263,7 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
).toBeGreaterThan(5);
});
-test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
+test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
const first = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage();
await page.goto(first);
@@ -286,7 +286,7 @@ test('reinjects after the target tab navigates', async ({ context, extensionId,
).toBe('true');
});
-test('re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
+test('@race re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/reloading-frame.html`;
const page = await context.newPage();
await page.goto(url);
@@ -313,7 +313,7 @@ test('re-attaches after the player frame swaps its document', async ({ context,
).toBe('true');
});
-test('re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
+test('@race re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/nested-frame.html`;
const page = await context.newPage();
await page.goto(url);
@@ -342,7 +342,7 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
).toBe('true');
});
-test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
+test('@race moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/player-css-switch.html`;
const page = await context.newPage();
await page.goto(url);
@@ -451,7 +451,7 @@ test('targets a visible nested cross-origin player and keeps top-page debug cont
});
});
-test('re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
+test('@race re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage();
await page.goto(url);
@@ -476,7 +476,7 @@ test('re-elects the visible cross-origin player after an iframe switch', async (
expect(await first.locator('video').evaluate(video => video.paused)).toBe(true);
});
-test('immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
+test('@race immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage();
await page.goto(url);
@@ -530,7 +530,7 @@ test('keeps commands flowing during continuous player-frame geometry changes', a
}
});
-test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
+test('@race deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
const secondUrl = `${baseURL}/pages/simple-player.html`;
const firstPage = await context.newPage();
@@ -580,7 +580,7 @@ test('re-attaches after a selected cross-origin frame navigates', async ({ conte
expect(state).toMatchObject({ found: true, inIframe: true });
});
-test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
+test('@race discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-late.html`;
const page = await context.newPage();
await page.goto(url);
@@ -668,7 +668,7 @@ test('selects the visible anime player nested behind a same-origin wrapper', asy
});
});
-test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
+test('@race selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
// The live case: at selection time the page has no video anywhere, because
// the host only builds the player when the viewer presses play.
const url = `${baseURL}/pages/yummy-deferred-player.html`;
@@ -737,7 +737,7 @@ test('polling video state on a page with no video does not restart the target',
.toBe('true');
});
-test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
+test('@race stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Live ad churn wakes the media-frame monitor several times a second. Each
// wake used to schedule a trailing refresh that rebuilt the target
@@ -804,7 +804,7 @@ test('controls and adopts a nested player even while the top frame is elected',
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true });
});
-test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
+test('@race recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Kodik rebuilds its player frame on quality and part changes, which kills
// the documentId the election is pinned to. The election has to be given up,
diff --git a/tests/e2e/helpers/extension-fixture.mjs b/tests/e2e/helpers/extension-fixture.mjs
index 9ce16c1..91f7806 100644
--- a/tests/e2e/helpers/extension-fixture.mjs
+++ b/tests/e2e/helpers/extension-fixture.mjs
@@ -7,30 +7,83 @@ import { test as base, chromium } from '@playwright/test';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export const extensionPath = path.join(repoRoot, 'dist/chrome');
+function isLocalTestUrl(rawUrl) {
+ try {
+ const url = new URL(rawUrl);
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) return true;
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
+ } catch (_error) {
+ return false;
+ }
+}
+
+export async function launchExtensionContext() {
+ if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
+ throw new Error('dist/chrome is missing. Run: npm run build:extension');
+ }
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
+ let context;
+ try {
+ context = await chromium.launchPersistentContext(userDataDir, {
+ channel: 'chromium',
+ headless: true,
+ args: [
+ `--disable-extensions-except=${extensionPath}`,
+ `--load-extension=${extensionPath}`,
+ '--autoplay-policy=no-user-gesture-required',
+ '--host-resolver-rules=MAP * 0.0.0.0, EXCLUDE localhost, EXCLUDE 127.0.0.1'
+ ]
+ });
+ await context.route('**/*', route => {
+ if (isLocalTestUrl(route.request().url())) return route.continue();
+ return route.abort('blockedbyclient');
+ });
+ if (typeof context.routeWebSocket === 'function') {
+ await context.routeWebSocket(/.*/u, webSocketRoute => {
+ if (isLocalTestUrl(webSocketRoute.url())) {
+ webSocketRoute.connectToServer();
+ } else {
+ webSocketRoute.close({ code: 1008, reason: 'External network blocked by E2E harness' });
+ }
+ });
+ }
+ let [worker] = context.serviceWorkers();
+ if (!worker) worker = await context.waitForEvent('serviceworker');
+ const extensionId = worker.url().split('/')[2];
+ return {
+ context,
+ extensionId,
+ async close() {
+ try {
+ await context.close();
+ } finally {
+ fs.rmSync(userDataDir, { recursive: true, force: true });
+ }
+ }
+ };
+ } catch (error) {
+ if (context) await context.close().catch(() => {});
+ if (fs.existsSync(userDataDir)) {
+ fs.rmSync(userDataDir, { recursive: true, force: true });
+ }
+ throw error;
+ }
+}
+
/**
* A browser with the packed extension loaded, plus its extension id. Each test
* gets a throwaway profile so storage from one test cannot leak into the next.
*/
export const test = base.extend({
context: async ({}, use) => {
- if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
- throw new Error('dist/chrome is missing. Run: npm run build:extension');
+ // The headless shell does not run MV3 service workers; the full
+ // Chromium build in new headless mode does.
+ const launched = await launchExtensionContext();
+ try {
+ await use(launched.context);
+ } finally {
+ await launched.close();
}
- const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
- const context = await chromium.launchPersistentContext(userDataDir, {
- // The headless shell does not run MV3 service workers; the full
- // Chromium build in new headless mode does.
- channel: 'chromium',
- headless: true,
- args: [
- `--disable-extensions-except=${extensionPath}`,
- `--load-extension=${extensionPath}`,
- '--autoplay-policy=no-user-gesture-required'
- ]
- });
- await use(context);
- await context.close();
- fs.rmSync(userDataDir, { recursive: true, force: true });
},
extensionId: async ({ context }, use) => {
let [worker] = context.serviceWorkers();
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
export async function writeStorage(page, values) {
return page.evaluate(v => chrome.storage.local.set(v), values);
}
+
+export async function terminateServiceWorker(context, extensionId) {
+ const page = await context.newPage();
+ let session;
+ try {
+ await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
+ session = await context.newCDPSession(page);
+ const { targetInfos } = await session.send('Target.getTargets');
+ const worker = targetInfos.find(target => target.type === 'service_worker'
+ && target.url.startsWith(`chrome-extension://${extensionId}/`));
+ if (!worker) throw new Error(`service worker target missing for ${extensionId}`);
+ await session.send('Target.closeTarget', { targetId: worker.targetId });
+ } finally {
+ if (session) await session.detach().catch(() => {});
+ await page.close().catch(() => {});
+ }
+}
diff --git a/tests/e2e/helpers/relay-process.mjs b/tests/e2e/helpers/relay-process.mjs
new file mode 100644
index 0000000..1b14965
--- /dev/null
+++ b/tests/e2e/helpers/relay-process.mjs
@@ -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('')}`);
+}
diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs
index a324214..4bc0d3a 100644
--- a/tests/e2e/playwright.config.mjs
+++ b/tests/e2e/playwright.config.mjs
@@ -12,19 +12,41 @@ export default defineConfig({
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
- reporter: process.env.CI ? 'list' : [['list']],
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: `http://localhost:${PORT}`,
- trace: 'retain-on-failure',
- launchOptions: {
- // Several fixtures hinge on a video actually playing. Without this
- // the browser's autoplay heuristics decide whether the fixture is
- // valid, which shows up later as an unexplained flake.
- args: ['--autoplay-policy=no-user-gesture-required']
- }
+ trace: 'retain-on-failure'
},
+ projects: [
+ {
+ name: 'detection-chromium',
+ testMatch: 'detection.spec.mjs',
+ use: {
+ browserName: 'chromium',
+ launchOptions: {
+ // Chromium alone supports this switch. Passing it through
+ // the shared config makes Linux WebKit refuse to launch.
+ args: ['--autoplay-policy=no-user-gesture-required']
+ }
+ }
+ },
+ {
+ name: 'detection-firefox',
+ testMatch: 'detection.spec.mjs',
+ use: { browserName: 'firefox' }
+ },
+ {
+ name: 'detection-webkit',
+ testMatch: 'detection.spec.mjs',
+ use: { browserName: 'webkit' }
+ },
+ {
+ name: 'extension-chromium',
+ testIgnore: 'detection.spec.mjs'
+ }
+ ],
webServer: {
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
url: `http://localhost:${PORT}/pages/simple-player.html`,
diff --git a/tests/e2e/popup-accessibility.spec.mjs b/tests/e2e/popup-accessibility.spec.mjs
new file mode 100644
index 0000000..b2e6c52
--- /dev/null
+++ b/tests/e2e/popup-accessibility.spec.mjs
@@ -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 || ''}`));
+ 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
+ });
+});
diff --git a/tests/e2e/room-sync.spec.mjs b/tests/e2e/room-sync.spec.mjs
new file mode 100644
index 0000000..d48a2df
--- /dev/null
+++ b/tests/e2e/room-sync.spec.mjs
@@ -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();
+ }
+});
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 6d58fdf..a5ff824 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -1,4 +1,5 @@
import { defineConfig } from 'vitest/config';
+import { VITEST_COVERAGE_INCLUDE } from './scripts/coverage-plan.mjs';
export default defineConfig({
test: {
@@ -10,17 +11,50 @@ export default defineConfig({
'shared/**/*.test.js',
'shared/**/*.test.mjs',
'extension/**/*.test.js',
- 'extension/**/*.test.mjs'
+ 'extension/**/*.test.mjs',
+ 'scripts/**/*.test.mjs'
],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
- include: ['server/**/*.js', 'shared/**/*.js'],
+ // Coverage is intentionally scoped to importable modules exercised
+ // by Vitest. Browser entry points and subprocess integration tests
+ // have separate E2E/integration gates and must not be reported as
+ // zero-coverage unit-test targets.
+ include: VITEST_COVERAGE_INCLUDE,
exclude: [
- '**/node_modules/**',
- '**/scripts/**',
- '**/extension/**'
- ]
+ '**/node_modules/**'
+ ],
+ thresholds: {
+ statements: 80,
+ branches: 68,
+ functions: 85,
+ lines: 83,
+ 'extension/media-frame-target.js': {
+ statements: 65,
+ branches: 50,
+ functions: 75,
+ lines: 67
+ },
+ 'extension/host-access.js': {
+ statements: 77,
+ branches: 66,
+ functions: 81,
+ lines: 79
+ },
+ 'server/rate-limiter.js': {
+ statements: 70,
+ branches: 58,
+ functions: 83,
+ lines: 74
+ },
+ 'scripts/release-artifact-checks.mjs': {
+ statements: 100,
+ branches: 95,
+ functions: 100,
+ lines: 100
+ }
+ }
}
}
});
diff --git a/website/llms.txt b/website/llms.txt
index 6401891..5ec8ba4 100644
--- a/website/llms.txt
+++ b/website/llms.txt
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
## Technical information
-- Current website release: 3.1.4
+- Current website release: 3.1.5
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
diff --git a/website/template.html b/website/template.html
index 41a316a..b204377 100644
--- a/website/template.html
+++ b/website/template.html
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
- "softwareVersion": "3.1.4",
+ "softwareVersion": "3.1.5",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
diff --git a/website/version.json b/website/version.json
index fed662e..1a06b0e 100644
--- a/website/version.json
+++ b/website/version.json
@@ -1,4 +1,4 @@
{
- "version": "3.1.4",
- "date": "2026-08-21T10:33:30Z"
+ "version": "3.1.5",
+ "date": "2026-08-24T21:56:28Z"
}