mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Compare commits
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
# Regenerate the visual-regression baselines IN the environment that compares
|
||||||
|
# them.
|
||||||
|
#
|
||||||
|
# The baselines in tests/visual/baselines.json are pixel-compared on
|
||||||
|
# ubuntu-latest CI, but they were historically captured on a developer machine.
|
||||||
|
# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira
|
||||||
|
# Code" are not shipped or installed anywhere, so every environment renders its
|
||||||
|
# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on
|
||||||
|
# CI while passing locally — ci.yml even documents the mismatch as the reason
|
||||||
|
# visual-regression must not block deploys. Capturing the baselines on the same
|
||||||
|
# runner image, browser install and font stack as the comparison removes the
|
||||||
|
# environment variable from the equation entirely.
|
||||||
|
#
|
||||||
|
# Two explicit triggers, both requiring deliberate human intent — regenerating
|
||||||
|
# baselines must never be a side effect of ordinary work:
|
||||||
|
#
|
||||||
|
# 1. workflow_dispatch — only usable once this file reaches the default
|
||||||
|
# branch (GitHub does not index dispatchable workflows on other branches).
|
||||||
|
# 2. A push whose head commit message contains [update-visual-baselines] —
|
||||||
|
# works from unstable today, before any promotion. The workflow's own
|
||||||
|
# commit omits the token, so it cannot retrigger itself.
|
||||||
|
name: Update visual baselines
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [unstable]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-baselines:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
contains(github.event.head_commit.message, '[update-visual-baselines]')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
# Mirror the visual-regression job's caches so the regenerated baselines
|
||||||
|
# come from the same dependency tree the comparison job will use.
|
||||||
|
- name: Cache npm directory
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-22-npm-
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: node-modules-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-22-modules-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Regenerate baselines
|
||||||
|
run: npm run test:visual:update
|
||||||
|
|
||||||
|
- name: Verify the fresh baselines pass in this environment
|
||||||
|
run: npx playwright test --project=visual
|
||||||
|
|
||||||
|
- name: Commit updated baselines
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- tests/visual/baselines.json; then
|
||||||
|
echo "Baselines unchanged; nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add tests/visual/baselines.json
|
||||||
|
git commit \
|
||||||
|
-m "test(visual): regenerate baselines on ubuntu-latest" \
|
||||||
|
-m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them."
|
||||||
|
git push
|
||||||
+26
-14
@@ -3,11 +3,10 @@
|
|||||||
## Test Infrastructure
|
## Test Infrastructure
|
||||||
|
|
||||||
### Missing `jsdom` Dependency
|
### Missing `jsdom` Dependency
|
||||||
- **Status:** Active
|
- **Status:** Resolved (2026-07-30)
|
||||||
- **Detected:** 2026-02-26
|
- **Detected:** 2026-02-26
|
||||||
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
|
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
|
||||||
- **Impact:** Prevents running unit tests that rely on DOM simulation.
|
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
|
||||||
- **Remediation:** `npm install --save-dev jsdom`
|
|
||||||
|
|
||||||
## Run Notes (2026-03-04)
|
## Run Notes (2026-03-04)
|
||||||
|
|
||||||
@@ -22,19 +21,32 @@
|
|||||||
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
|
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
|
||||||
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
|
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
|
||||||
|
|
||||||
## Run Notes (2026-06-20)
|
## Run Notes (2026-06-20, updated 2026-07-30)
|
||||||
|
|
||||||
### Additional E2E specs flake under full parallel load (pass in isolation)
|
### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout
|
||||||
|
- **Status:** Active — NOT merely a parallel-load flake
|
||||||
|
- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified.
|
||||||
|
- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes.
|
||||||
|
- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem.
|
||||||
|
|
||||||
|
### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load
|
||||||
- **Status:** Active
|
- **Status:** Active
|
||||||
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
|
- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention.
|
||||||
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
|
- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green.
|
||||||
|
|
||||||
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
|
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
|
||||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
- **Status:** Resolved (2026-07-28, `f48150ed`)
|
||||||
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
|
- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there.
|
||||||
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
|
- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs.
|
||||||
|
|
||||||
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
|
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
|
||||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
- **Status:** Not reproducible (2026-07-30)
|
||||||
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
|
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
|
||||||
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
|
- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it.
|
||||||
|
|
||||||
|
## Run Notes (2026-07-30)
|
||||||
|
|
||||||
|
### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff
|
||||||
|
- **Status:** By design (since `ab120a03`)
|
||||||
|
- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green.
|
||||||
|
- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine.
|
||||||
+1
-43
@@ -9,7 +9,6 @@
|
|||||||
* Methods use `this` which is bound to the Application instance.
|
* Methods use `this` which is bound to the Application instance.
|
||||||
*/
|
*/
|
||||||
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
||||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
|
||||||
|
|
||||||
export function createUiCoordinator(deps) {
|
export function createUiCoordinator(deps) {
|
||||||
const {
|
const {
|
||||||
@@ -188,52 +187,11 @@ export function createUiCoordinator(deps) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
syncAuthUiState() {
|
syncAuthUiState() {
|
||||||
// Auth-dependent chrome has to follow login/logout/account switches, but
|
if (this.isUserLoggedIn()) {
|
||||||
// syncAuthUiState() was only ever called at bootstrap — so everything it
|
|
||||||
// gates (notably the share menu's "Share on Nostr" item) kept its
|
|
||||||
// logged-out state for the entire session. Self-install a listener on the
|
|
||||||
// first run rather than adding a call site in js/app.js, which is at its
|
|
||||||
// file-size cap.
|
|
||||||
if (
|
|
||||||
!this.__authUiSyncBound &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window?.addEventListener === "function"
|
|
||||||
) {
|
|
||||||
this.__authUiSyncBound = true;
|
|
||||||
window.addEventListener("bitvid:auth-changed", () => {
|
|
||||||
try {
|
|
||||||
this.syncAuthUiState();
|
|
||||||
} catch (error) {
|
|
||||||
devLogger.warn("[Application] Auth UI resync failed:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const loggedIn = this.isUserLoggedIn();
|
|
||||||
if (loggedIn) {
|
|
||||||
this.applyAuthenticatedUiState();
|
this.applyAuthenticatedUiState();
|
||||||
} else {
|
} else {
|
||||||
this.applyLoggedOutUiState();
|
this.applyLoggedOutUiState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate the share menu's "Share on Nostr" item. Without this the modal's
|
|
||||||
// shareNostrAuthState stays at its initial `false` forever and the item
|
|
||||||
// renders permanently disabled — indistinguishable from plain text.
|
|
||||||
if (typeof this.videoModal?.setShareNostrAuthState === "function") {
|
|
||||||
let signer = null;
|
|
||||||
try {
|
|
||||||
signer = getActiveSigner();
|
|
||||||
} catch (error) {
|
|
||||||
devLogger.warn("[Application] Failed to read active signer:", error);
|
|
||||||
}
|
|
||||||
this.videoModal.setShareNostrAuthState({
|
|
||||||
isLoggedIn: loggedIn,
|
|
||||||
// Mirrors ShareNostrController.handleShare(): a signer that cannot
|
|
||||||
// sign is no signer at all, and enabling the item would only produce
|
|
||||||
// an error toast on click.
|
|
||||||
hasSigner: !!signer && typeof signer.signEvent === "function",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshChromeElements() {
|
refreshChromeElements() {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
[build]
|
|
||||||
command = "npm run build && npm run verify:dist:deploy-artifact"
|
|
||||||
publish = "dist"
|
|
||||||
|
|
||||||
[build.environment]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/*"
|
|
||||||
[headers.values]
|
|
||||||
Content-Security-Policy = "frame-ancestors *"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/index.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/embed.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/*.html"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "no-cache, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/css/*"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=31536000, immutable"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/js/*"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=31536000, immutable"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/sw.min.js"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=0, must-revalidate"
|
|
||||||
|
|
||||||
[[headers]]
|
|
||||||
for = "/site.webmanifest"
|
|
||||||
[headers.values]
|
|
||||||
Cache-Control = "public, max-age=0, must-revalidate"
|
|
||||||
@@ -33,6 +33,43 @@ if (packageJson.name !== "@bitlogin/widget") {
|
|||||||
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
|
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored
|
||||||
|
// bytes have not changed since they were committed; they cannot say WHICH
|
||||||
|
// BitLogin commit produced them. @bitlogin/widget is a private package pinned
|
||||||
|
// at 0.1.0 across many substantive changes, so the version string distinguishes
|
||||||
|
// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so
|
||||||
|
// refusing a dirty checkout leaves the committed vendored files untouched.
|
||||||
|
const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim();
|
||||||
|
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
const dirty = git("status --porcelain") !== "";
|
||||||
|
if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") {
|
||||||
|
throw new Error(
|
||||||
|
"DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this "
|
||||||
|
+ "would produce corresponds to no commit and cannot be reproduced. Commit there first, "
|
||||||
|
+ "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
source = {
|
||||||
|
repository: git("remote get-url origin"),
|
||||||
|
commit: git("rev-parse HEAD"),
|
||||||
|
commitDate: git("log -1 --format=%cI"),
|
||||||
|
dirty,
|
||||||
|
provenance: dirty ? "dirty" : "attributed",
|
||||||
|
toolchain: { node: process.version, vite: packageJson.devDependencies?.vite },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.startsWith("DIRTY_CHECKOUT:")) {
|
||||||
|
throw new Error(error.message.replace("DIRTY_CHECKOUT: ", ""));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. `
|
||||||
|
+ "Vendoring from a non-git source leaves the artifact unattributable; refusing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`);
|
||||||
|
|
||||||
if (!existsSync(widgetDist)) {
|
if (!existsSync(widgetDist)) {
|
||||||
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
|
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
|
||||||
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
|
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
|
||||||
@@ -56,7 +93,7 @@ for (const name of distEntries) {
|
|||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
integrityFile,
|
integrityFile,
|
||||||
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
|
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
"/js/search/searchFilters.js"
|
"/js/search/searchFilters.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 9afbc0cb ("Decode npub authors to hex") made the author filter validate
|
||||||
|
// its value, because npub-form authors silently matched nothing. The old
|
||||||
|
// fixture used `author:abc123`, which is neither a decodable npub nor
|
||||||
|
// 64-char hex, so the filter is now (correctly) rejected and
|
||||||
|
// authorPubkeys comes back empty. Use a real pubkey to exercise the
|
||||||
|
// success path, and pin the rejection separately below.
|
||||||
|
const AUTHOR_HEX = "a".repeat(64);
|
||||||
|
|
||||||
const parsed = parseFilterQuery(
|
const parsed = parseFilterQuery(
|
||||||
'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm',
|
`author:${AUTHOR_HEX} tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const withErrors = parseFilterQuery(
|
const withErrors = parseFilterQuery(
|
||||||
"duration:oops has:bad unknown:value before:not-a-date",
|
"duration:oops has:bad unknown:value before:not-a-date author:abc123",
|
||||||
);
|
);
|
||||||
|
|
||||||
const serialized = serializeFiltersToQuery(parsed.filters);
|
const serialized = serializeFiltersToQuery(parsed.filters);
|
||||||
@@ -30,7 +38,7 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]);
|
expect(result.parsed.filters.authorPubkeys).toEqual(["a".repeat(64)]);
|
||||||
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
|
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
|
||||||
expect(result.parsed.filters.kind).toBe(30078);
|
expect(result.parsed.filters.kind).toBe(30078);
|
||||||
expect(result.parsed.filters.relay).toBe("wss://relay.example");
|
expect(result.parsed.filters.relay).toBe("wss://relay.example");
|
||||||
@@ -44,8 +52,13 @@ test.describe("runtime utility module coverage", () => {
|
|||||||
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
|
||||||
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
|
||||||
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
|
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
|
||||||
|
// An undecodable author must be reported rather than silently dropped.
|
||||||
|
expect(
|
||||||
|
result.withErrors.errors.some((e: any) => /Could not decode author/.test(e.message)),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.withErrors.filters.authorPubkeys).toEqual([]);
|
||||||
|
|
||||||
expect(result.serialized).toContain("author:abc123");
|
expect(result.serialized).toContain(`author:${"a".repeat(64)}`);
|
||||||
expect(result.serialized).toContain("tag:music");
|
expect(result.serialized).toContain("tag:music");
|
||||||
expect(result.serialized).toContain("kind:30078");
|
expect(result.serialized).toContain("kind:30078");
|
||||||
expect(result.serialized).toContain("has:magnet");
|
expect(result.serialized).toContain("has:magnet");
|
||||||
|
|||||||
@@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
createdAt: 42,
|
createdAt: 42,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A legacy plaintext entry must be PURGED, not honored. readV1Entry()
|
||||||
|
// removes it from storage; readStoredSessionActorEntry() then falls back
|
||||||
|
// to the sole remaining (encrypted) account, so the old `=== null`
|
||||||
|
// assertion no longer describes the security property -- it just happened
|
||||||
|
// to hold before 8692dac6 added per-account storage. Assert the property
|
||||||
|
// directly instead: no plaintext key survives, in the returned entry or
|
||||||
|
// in raw storage.
|
||||||
const migratedLegacyEntry = readStoredSessionActorEntry();
|
const migratedLegacyEntry = readStoredSessionActorEntry();
|
||||||
|
const legacyRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
|
||||||
|
let legacyRawPlaintextSurvives = false;
|
||||||
|
try {
|
||||||
|
legacyRawPlaintextSurvives = Boolean(legacyRaw && JSON.parse(legacyRaw)?.privateKey);
|
||||||
|
} catch (error) {
|
||||||
|
legacyRawPlaintextSurvives = false;
|
||||||
|
}
|
||||||
|
|
||||||
const actorMatch = isSessionActor({
|
const actorMatch = isSessionActor({
|
||||||
pubkey: actor,
|
pubkey: actor,
|
||||||
@@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
wrongPassphraseErrorCode,
|
wrongPassphraseErrorCode,
|
||||||
metadataValid: metadata && metadata.salt === encrypted.salt,
|
metadataValid: metadata && metadata.salt === encrypted.salt,
|
||||||
metadataInvalidIsNull: invalidMetadata === null,
|
metadataInvalidIsNull: invalidMetadata === null,
|
||||||
migratedLegacyEntryIsNull: migratedLegacyEntry === null,
|
migratedLegacyEntryHasPlaintextKey:
|
||||||
|
typeof (migratedLegacyEntry as any)?.privateKey === "string" &&
|
||||||
|
(migratedLegacyEntry as any).privateKey.length > 0,
|
||||||
|
legacyRawPlaintextSurvives,
|
||||||
actorMatch,
|
actorMatch,
|
||||||
actorMismatch,
|
actorMismatch,
|
||||||
nsecActor,
|
nsecActor,
|
||||||
@@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => {
|
|||||||
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
|
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
|
||||||
expect(result.metadataValid).toBeTruthy();
|
expect(result.metadataValid).toBeTruthy();
|
||||||
expect(result.metadataInvalidIsNull).toBe(true);
|
expect(result.metadataInvalidIsNull).toBe(true);
|
||||||
expect(result.migratedLegacyEntryIsNull).toBe(true);
|
expect(result.migratedLegacyEntryHasPlaintextKey).toBe(false);
|
||||||
|
expect(result.legacyRawPlaintextSurvives).toBe(false);
|
||||||
expect(result.actorMatch).toBe(true);
|
expect(result.actorMatch).toBe(true);
|
||||||
expect(result.actorMismatch).toBe(false);
|
expect(result.actorMismatch).toBe(false);
|
||||||
expect(result.nsecActor).toBe(false);
|
expect(result.nsecActor).toBe(false);
|
||||||
|
|||||||
@@ -211,8 +211,16 @@ test.describe("component modal pages", () => {
|
|||||||
waitUntil: "networkidle",
|
waitUntil: "networkidle",
|
||||||
});
|
});
|
||||||
|
|
||||||
const modal = page.locator(".bv-modal");
|
// A component page may legitimately ship more than one modal root:
|
||||||
await expect(modal).toHaveCount(1);
|
// profile-modal.html also bundles #storageCorsModal, the B2/S3 CORS setup
|
||||||
|
// helper added in d7ad1516. Asserting exactly one was incidental to what
|
||||||
|
// this test is actually for -- "the page renders a modal that opens and
|
||||||
|
// closes" -- so require at least one and drive the primary one, which is
|
||||||
|
// what the injected script's document.querySelector(".bv-modal") already
|
||||||
|
// operates on.
|
||||||
|
const modals = page.locator(".bv-modal");
|
||||||
|
expect(await modals.count()).toBeGreaterThan(0);
|
||||||
|
const modal = modals.first();
|
||||||
|
|
||||||
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
|
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
|
||||||
await page.addScriptTag({
|
await page.addScriptTag({
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
|
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
|
||||||
|
// Record every src the player attaches. Asserting the FINAL src is wrong:
|
||||||
|
// this test already tolerates "No playable source found" in its status
|
||||||
|
// check (headless CI often cannot decode the archive.org H.264 sample), and
|
||||||
|
// when the attempt fails resetVideoElement() clears src back to "". The
|
||||||
|
// behaviour under test is that CDN mode ATTACHES the hosted URL, which is
|
||||||
|
// observable regardless of whether the remote file then decodes.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__attachedSrcs = [];
|
||||||
|
new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const target = mutation.target as HTMLElement;
|
||||||
|
if (mutation.attributeName === "src" && target.tagName === "VIDEO") {
|
||||||
|
const value = target.getAttribute("src");
|
||||||
|
if (value) {
|
||||||
|
(window as any).__attachedSrcs.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, {
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["src"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Trigger playback with just the URL
|
// 1. Trigger playback with just the URL
|
||||||
await page.evaluate(async (url) => {
|
await page.evaluate(async (url) => {
|
||||||
const { getApplication } = await import("/js/applicationContext.js");
|
const { getApplication } = await import("/js/applicationContext.js");
|
||||||
@@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => {
|
|||||||
const urlToggle = page.locator('[data-source-toggle="url"]');
|
const urlToggle = page.locator('[data-source-toggle="url"]');
|
||||||
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
|
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
// Check if video is attempting to play from source
|
// Check the player attached the hosted URL (see the observer above).
|
||||||
const video = page.locator("#modalVideo");
|
await expect
|
||||||
await expect(video).toHaveAttribute("src", VIDEO_URL);
|
.poll(async () => await page.evaluate(() => (window as any).__attachedSrcs))
|
||||||
|
.toContain(VIDEO_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
|
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
|
||||||
|
|||||||
@@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async (
|
|||||||
|
|
||||||
service.recomputeSummaryForEvent(eventId);
|
service.recomputeSummaryForEvent(eventId);
|
||||||
|
|
||||||
|
// 18e08bf2 ("refine trust controls") removed `status.whitelisted ||` from
|
||||||
|
// isTrustedReporter and updated the doc comment from "trustedContacts set (or
|
||||||
|
// Admin Whitelist)" to "trustedContacts set". Being on the admin whitelist is
|
||||||
|
// permission to publish, not a statement that your reports carry weight, so a
|
||||||
|
// whitelisted reporter now counts toward the report TOTAL but not toward
|
||||||
|
// TRUSTED.
|
||||||
|
//
|
||||||
|
// Counted reporters: trusted (t=100), whitelisted (t=400), random (t=300).
|
||||||
|
// Excluded entirely: blacklisted (admin list) and blocked (personal block).
|
||||||
const summary = service.getTrustedReportSummary(eventId);
|
const summary = service.getTrustedReportSummary(eventId);
|
||||||
assert.equal(summary.totalTrusted, 2);
|
assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted");
|
||||||
assert.equal(summary.types.nudity.total, 3);
|
assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report");
|
||||||
assert.equal(summary.types.nudity.trusted, 2);
|
assert.equal(summary.types.nudity.trusted, 1);
|
||||||
assert.equal(summary.types.nudity.latest, 400);
|
assert.equal(
|
||||||
|
summary.types.nudity.latest,
|
||||||
|
400,
|
||||||
|
"latest still reflects the whitelisted reporter's timestamp"
|
||||||
|
);
|
||||||
|
|
||||||
const reporters = service.getTrustedReporters(eventId, "nudity");
|
const reporters = service.getTrustedReporters(eventId, "nudity");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reporters.map((entry) => entry.pubkey),
|
reporters.map((entry) => entry.pubkey),
|
||||||
[whitelistedHex, trustedHex]
|
[trustedHex],
|
||||||
|
"the whitelisted reporter is no longer surfaced as a trusted reporter"
|
||||||
);
|
);
|
||||||
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
|
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user block updates recompute summaries and emit notifications", async (t) => {
|
test("user block updates recompute summaries and emit notifications", async (t) => {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33).
|
||||||
|
//
|
||||||
|
// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared
|
||||||
|
// nip46Client being torn down by any scoped logout. Since then the layers under
|
||||||
|
// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted
|
||||||
|
// restore) — but nothing pinned the MANAGER-level contract, so any of those
|
||||||
|
// fixes could regress silently. These tests exercise the real SignerManager
|
||||||
|
// against the real signer registry and real (polyfilled) storage.
|
||||||
|
//
|
||||||
|
// test_integrity_note:
|
||||||
|
// change_type: ["new_tests"]
|
||||||
|
// scenarios:
|
||||||
|
// - id: SCN-nip46-logout-scope
|
||||||
|
// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer"
|
||||||
|
// when: "SignerManager.logout() logs out A"
|
||||||
|
// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives"
|
||||||
|
// - id: SCN-nip46-restore-failure-scope
|
||||||
|
// given: "stored sessions for A and B"
|
||||||
|
// when: "a targeted restore of A fails (locked session / missing session)"
|
||||||
|
// then: "the failure is typed and NO stored session is deleted"
|
||||||
|
// observable_outcomes:
|
||||||
|
// - "fake client's destroy() call count"
|
||||||
|
// - "readStoredNip46Session(pubkey) after the operation"
|
||||||
|
// - "resolveActiveSigner(pubkey) for the OTHER account"
|
||||||
|
// determinism_controls:
|
||||||
|
// - "in-memory localStorage polyfill; fake in-process client object; no network"
|
||||||
|
// anti_cheat_rationale:
|
||||||
|
// prevents: ["over-mocking internal logic", "hard-coded return value"]
|
||||||
|
// relaxation:
|
||||||
|
// did_relax_any_assertion: false
|
||||||
|
|
||||||
|
import "./test-helpers/setup-localstorage.mjs";
|
||||||
|
import test, { beforeEach } from "node:test";
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { SignerManager } from "../js/nostr/managers/SignerManager.js";
|
||||||
|
import {
|
||||||
|
writeStoredNip46SessionSync,
|
||||||
|
readStoredNip46Session,
|
||||||
|
} from "../js/nostr/nip46Client.js";
|
||||||
|
import {
|
||||||
|
registerSigner,
|
||||||
|
setActiveSigner,
|
||||||
|
clearActiveSigner,
|
||||||
|
resolveActiveSigner,
|
||||||
|
} from "../js/nostrClientRegistry.js";
|
||||||
|
|
||||||
|
const USER_A =
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000a1";
|
||||||
|
const USER_B =
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000b2";
|
||||||
|
|
||||||
|
function storedSession(userPubkey, remotePubkey) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
clientPublicKey:
|
||||||
|
"3333333333333333333333333333333333333333333333333333333333333333",
|
||||||
|
remotePubkey,
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
encryption: "nip44.v2",
|
||||||
|
userPubkey,
|
||||||
|
lastConnectedAt: 1700000000000,
|
||||||
|
// Sessions at rest carry encrypted secrets; restoring without the
|
||||||
|
// passphrase must fail typed, not fall through to a broken connect.
|
||||||
|
encryptedSecrets: "cipher",
|
||||||
|
keyEncryption: {
|
||||||
|
salt: "c2FsdA==",
|
||||||
|
iv: "aXY=",
|
||||||
|
iterations: 1000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeManager() {
|
||||||
|
// SignerManager only needs the app client for pool access on connect paths;
|
||||||
|
// the scoping contracts under test never reach the network.
|
||||||
|
return new SignerManager({
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
ensurePool: async () => {
|
||||||
|
throw new Error("network must not be touched by these tests");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
clearActiveSigner();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logging out the active NIP-46 account keeps every other account intact", () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
// B is a signed-in account in the registry (e.g. logged in earlier this
|
||||||
|
// session); A is the ACTIVE account with the live remote-signer client.
|
||||||
|
const signerB = { pubkey: USER_B, signEvent: async (e) => e };
|
||||||
|
registerSigner(USER_B, signerB);
|
||||||
|
|
||||||
|
let destroyCalls = 0;
|
||||||
|
const liveClient = {
|
||||||
|
remotePubkey: "1".repeat(64),
|
||||||
|
userPubkey: USER_A,
|
||||||
|
destroy() {
|
||||||
|
destroyCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.pubkey = USER_A;
|
||||||
|
manager.nip46Client = liveClient;
|
||||||
|
|
||||||
|
manager.logout();
|
||||||
|
|
||||||
|
// The departing account's LIVE connection is torn down exactly once…
|
||||||
|
assert.equal(destroyCalls, 1);
|
||||||
|
assert.equal(manager.nip46Client, null);
|
||||||
|
assert.equal(manager.pubkey, null);
|
||||||
|
|
||||||
|
// …but its stored session survives (logout keeps sessions reconnectable —
|
||||||
|
// that is what makes "switch back later" possible)…
|
||||||
|
const storedA = readStoredNip46Session(USER_A);
|
||||||
|
assert.ok(storedA, "A's stored session must survive A's own logout");
|
||||||
|
assert.equal(storedA.remotePubkey, "1".repeat(64));
|
||||||
|
|
||||||
|
// …and account B is completely untouched: stored session AND registry signer.
|
||||||
|
const storedB = readStoredNip46Session(USER_B);
|
||||||
|
assert.ok(storedB, "B's stored session must survive A's logout");
|
||||||
|
assert.equal(storedB.remotePubkey, "2".repeat(64));
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveSigner(USER_B),
|
||||||
|
signerB,
|
||||||
|
"B's registered signer must survive A's logout"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout is safe when no remote client is live (nsec/nip07 active)", () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.pubkey = USER_A; // active via some non-NIP-46 method
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => manager.logout());
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"an unrelated account's NIP-46 session must survive a non-NIP-46 logout"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed targeted restore deletes nothing", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
|
||||||
|
// A's session is passphrase-locked and none was provided: typed failure.
|
||||||
|
await assert.rejects(
|
||||||
|
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
|
||||||
|
(error) => error?.code === "passphrase-required"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore");
|
||||||
|
assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoring an account with no stored session fails typed and touches nothing", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
|
||||||
|
(error) => error?.code === "no-stored-session"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"an unrelated stored session must survive a missing-session restore attempt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => {
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.nip46Client = { destroy() {} };
|
||||||
|
|
||||||
|
await manager.disconnectRemoteSigner({ keepStored: true });
|
||||||
|
|
||||||
|
assert.ok(readStoredNip46Session(USER_A));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => {
|
||||||
|
// The no-arg clear historically wiped the whole v2 map — "after switching
|
||||||
|
// away you could no longer switch back". Pin the scoped behavior: the v1
|
||||||
|
// default slot goes, the per-account map survives.
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
|
||||||
|
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
|
||||||
|
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.nip46Client = { destroy() {} };
|
||||||
|
|
||||||
|
await manager.disconnectRemoteSigner();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_A),
|
||||||
|
"per-account session A must survive a bare disconnect"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
readStoredNip46Session(USER_B),
|
||||||
|
"per-account session B must survive a bare disconnect"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -267,8 +267,18 @@ describe("PlaybackService Forced Source Logic", () => {
|
|||||||
assert.equal(probeUrl.mock.callCount(), 0);
|
assert.equal(probeUrl.mock.callCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
|
test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => {
|
||||||
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
|
// Spec changed deliberately in playbackService.js: "Even when P2P was
|
||||||
|
// explicitly forced, a hosted URL is a better outcome than a hard 'No
|
||||||
|
// playable source found' if the torrent DEFINITIVELY failed." The code
|
||||||
|
// carries the rollback note (`restore && forcedSource !== "torrent"` to
|
||||||
|
// make forced P2P hard-fail), so the old assertion here -- source null,
|
||||||
|
// probe never called -- encodes behavior that was intentionally removed.
|
||||||
|
//
|
||||||
|
// It did not merely fail: because forced sources skip the start timeout,
|
||||||
|
// the now-reachable hosted attempt waited forever on a <video> that never
|
||||||
|
// emits, and the test hung ("Promise resolution is still pending").
|
||||||
|
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||||
|
|
||||||
const playViaWebTorrent = mock.fn(async () => {
|
const playViaWebTorrent = mock.fn(async () => {
|
||||||
throw new Error("Torrent failed");
|
throw new Error("Torrent failed");
|
||||||
@@ -283,9 +293,18 @@ describe("PlaybackService Forced Source Logic", () => {
|
|||||||
forcedSource: "torrent",
|
forcedSource: "torrent",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await session.start();
|
const startPromise = session.start();
|
||||||
|
|
||||||
assert.equal(result.source, null);
|
// Let the torrent attempt reject and the hosted fallback attach, then
|
||||||
assert.equal(probeUrl.mock.callCount(), 0);
|
// signal that the element actually started playing.
|
||||||
|
await new Promise(resolve => process.nextTick(resolve));
|
||||||
|
mock.timers.tick(10);
|
||||||
|
await new Promise(resolve => process.nextTick(resolve));
|
||||||
|
video.dispatchEvent(new window.Event("playing"));
|
||||||
|
|
||||||
|
const result = await startPromise;
|
||||||
|
|
||||||
|
assert.equal(playViaWebTorrent.mock.callCount(), 1, "P2P is still tried first");
|
||||||
|
assert.equal(result.source, "url", "a working CDN beats a hard failure");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -133,14 +133,24 @@ describe("trustBootstrap", () => {
|
|||||||
assert.ok(seeds.has("editor1"));
|
assert.ok(seeds.has("editor1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should subscribe to accessControl changes", async () => {
|
it("should subscribe to the accessControl lists that affect seeds", async () => {
|
||||||
await bootstrapTrustedSeeds();
|
await bootstrapTrustedSeeds();
|
||||||
|
|
||||||
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 1);
|
|
||||||
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
|
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
|
||||||
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
|
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should NOT subscribe to whitelist changes", async () => {
|
||||||
|
// buildTrustedSeeds() derives seeds from the super admin, the editors list
|
||||||
|
// and the fallback seeds -- the whitelist is not an input, so re-applying
|
||||||
|
// seeds on a whitelist change is pure churn. 18e08bf2 ("refine trust
|
||||||
|
// controls") removed that subscription deliberately; this pins the removal
|
||||||
|
// so it is not reinstated by reflex.
|
||||||
|
await bootstrapTrustedSeeds();
|
||||||
|
|
||||||
|
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle accessControl timeout and apply seeds anyway", async () => {
|
it("should handle accessControl timeout and apply seeds anyway", async () => {
|
||||||
// Mock waitForAccessControl to timeout (reject)
|
// Mock waitForAccessControl to timeout (reject)
|
||||||
accessControl.waitForReady.mock.mockImplementation(async () => {
|
accessControl.waitForReady.mock.mockImplementation(async () => {
|
||||||
|
|||||||
@@ -13,13 +13,7 @@
|
|||||||
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createUiCoordinator } from "../js/app/uiCoordinator.js";
|
|
||||||
import { VideoModal } from "../js/ui/components/VideoModal.js";
|
import { VideoModal } from "../js/ui/components/VideoModal.js";
|
||||||
import {
|
|
||||||
clearActiveSigner,
|
|
||||||
registerSigner,
|
|
||||||
setActiveSigner,
|
|
||||||
} from "../js/nostrClientRegistry.js";
|
|
||||||
|
|
||||||
const PUBKEY = "b".repeat(64);
|
const PUBKEY = "b".repeat(64);
|
||||||
|
|
||||||
@@ -71,103 +65,44 @@ test("setShareNostrAuthState tolerates no popover", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- syncAuthUiState wiring ----------------------------------------------
|
// --- app wiring ----------------------------------------------------------
|
||||||
|
// bitvid ALREADY routed auth changes to the modal: Application
|
||||||
|
// .updateShareNostrAuthState() is called on init, login, logout, pubkey-change
|
||||||
|
// and signer-change, and it forwards to videoModal.setShareNostrAuthState().
|
||||||
|
// That method simply did not exist, so the call short-circuited on its own
|
||||||
|
// `if (!this.videoModal?.setShareNostrAuthState) return;` guard and the gate
|
||||||
|
// stayed shut forever. These pin the contract that guard depends on.
|
||||||
|
|
||||||
function makeApp({ loggedIn }) {
|
test("the app's updateShareNostrAuthState contract is satisfied", async () => {
|
||||||
const coordinator = createUiCoordinator({
|
const { readFile } = await import("node:fs/promises");
|
||||||
devLogger: { warn() {}, error() {}, log() {} },
|
const source = await readFile(new URL("../js/app.js", import.meta.url), "utf8");
|
||||||
});
|
|
||||||
const pushed = [];
|
|
||||||
const app = {
|
|
||||||
...coordinator,
|
|
||||||
isUserLoggedIn: () => loggedIn,
|
|
||||||
// Stub the DOM-touching branches; this test is about the share gate.
|
|
||||||
applyAuthenticatedUiState() {},
|
|
||||||
applyLoggedOutUiState() {},
|
|
||||||
videoModal: {
|
|
||||||
setShareNostrAuthState(state) {
|
|
||||||
pushed.push({ ...state });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { app, pushed };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("syncAuthUiState pushes the gate into the video modal", () => {
|
// The guard short-circuits when the method is absent, which is exactly how
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
// this stayed broken silently. If the method is ever renamed, fail loudly.
|
||||||
app.syncAuthUiState();
|
assert.match(source, /videoModal\?\.setShareNostrAuthState/);
|
||||||
assert.equal(pushed.length, 1);
|
assert.match(source, /videoModal\.setShareNostrAuthState\(\{/);
|
||||||
assert.equal(pushed[0].isLoggedIn, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logged out propagates as a closed gate", () => {
|
// The lifecycle points that must keep the gate in sync.
|
||||||
const { app, pushed } = makeApp({ loggedIn: false });
|
for (const reason of ["init", "signer-change", "pubkey-change"]) {
|
||||||
app.syncAuthUiState();
|
assert.ok(
|
||||||
assert.deepEqual(pushed[0], { isLoggedIn: false, hasSigner: false });
|
source.includes(`reason: "${reason}"`),
|
||||||
});
|
`app.js should refresh the share gate on ${reason}`
|
||||||
|
|
||||||
test("hasSigner reflects a registered, signing-capable signer", () => {
|
|
||||||
clearActiveSigner();
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
|
||||||
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[0].hasSigner, false, "no signer registered yet");
|
|
||||||
|
|
||||||
registerSigner(PUBKEY, { pubkey: PUBKEY, signEvent: async (e) => e });
|
|
||||||
setActiveSigner(PUBKEY);
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[1].hasSigner, true);
|
|
||||||
|
|
||||||
clearActiveSigner();
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[2].hasSigner, false, "logout must close the gate again");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a signer that cannot sign does not open the gate", () => {
|
|
||||||
// Mirrors ShareNostrController.handleShare(): enabling the item for a signer
|
|
||||||
// without signEvent would only produce an error toast on click.
|
|
||||||
clearActiveSigner();
|
|
||||||
registerSigner(PUBKEY, { pubkey: PUBKEY });
|
|
||||||
setActiveSigner(PUBKEY);
|
|
||||||
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: true });
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed[0].hasSigner, false);
|
|
||||||
clearActiveSigner();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("syncAuthUiState re-runs on bitvid:auth-changed, and binds only once", () => {
|
|
||||||
// The original bug's second half: the gate was computed once at bootstrap and
|
|
||||||
// never again, so logging in mid-session left the button disabled.
|
|
||||||
const listeners = [];
|
|
||||||
const originalWindow = globalThis.window;
|
|
||||||
globalThis.window = {
|
|
||||||
addEventListener(type, handler) {
|
|
||||||
listeners.push({ type, handler });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { app, pushed } = makeApp({ loggedIn: false });
|
|
||||||
app.syncAuthUiState();
|
|
||||||
assert.equal(pushed.length, 1);
|
|
||||||
|
|
||||||
const bound = listeners.filter((l) => l.type === "bitvid:auth-changed");
|
|
||||||
assert.equal(bound.length, 1, "listener installed on first sync");
|
|
||||||
|
|
||||||
bound[0].handler();
|
|
||||||
assert.equal(pushed.length, 2, "auth change re-syncs the gate");
|
|
||||||
|
|
||||||
// Re-entrancy: the resync must not stack a new listener every time.
|
|
||||||
assert.equal(
|
|
||||||
listeners.filter((l) => l.type === "bitvid:auth-changed").length,
|
|
||||||
1
|
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (originalWindow === undefined) {
|
|
||||||
delete globalThis.window;
|
|
||||||
} else {
|
|
||||||
globalThis.window = originalWindow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
typeof VideoModal.prototype.setShareNostrAuthState,
|
||||||
|
"function",
|
||||||
|
"VideoModal must expose the method app.js calls"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login and logout both refresh the gate", async () => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../js/app/authSessionCoordinator.js", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('reason: "auth-login"'));
|
||||||
|
assert.ok(source.includes('reason: "auth-logout"'));
|
||||||
});
|
});
|
||||||
@@ -125,14 +125,33 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
|
|||||||
});
|
});
|
||||||
|
|
||||||
await t.test("trusted mute blur state matches moderation context", async () => {
|
await t.test("trusted mute blur state matches moderation context", async () => {
|
||||||
|
// Fixture mirrors what moderationDecorator actually emits. Two things
|
||||||
|
// made the old hand-rolled shape stop exercising this path:
|
||||||
|
//
|
||||||
|
// 1. normalizeVideoModerationContext derives `shouldShow` from
|
||||||
|
// `original.blurThumbnail` and `trustedCount`, NOT from a flat
|
||||||
|
// `blurThumbnail`/`trustedMuteCount`. The old fixture set fields the
|
||||||
|
// helper never reads, so shouldShow was false and applyModerationOverlay
|
||||||
|
// took its early return and deleted the blur it was asserting on.
|
||||||
|
// 2. 4525c1c6 made a lone trusted mute a non-blocking ranking signal (see
|
||||||
|
// "VideoCard keeps a single trusted mute as a non-blocking ranking
|
||||||
|
// signal"), so trusted-mute alone no longer forces the overlay.
|
||||||
|
//
|
||||||
|
// The scenario under test is still "a trusted mute that DID trigger a
|
||||||
|
// blur renders blurred with a trusted-mute badge", so the fixture now
|
||||||
|
// carries the decorator's `original` block that represents exactly that.
|
||||||
const video = {
|
const video = {
|
||||||
id: "event-trusted-mute",
|
id: "event-trusted-mute",
|
||||||
title: "Trusted mute video",
|
title: "Trusted mute video",
|
||||||
moderation: {
|
moderation: {
|
||||||
blurThumbnail: true,
|
blurThumbnail: true,
|
||||||
|
blurReason: "trusted-mute",
|
||||||
trustedMuted: true,
|
trustedMuted: true,
|
||||||
|
trustedCount: 2,
|
||||||
trustedMuteCount: 2,
|
trustedMuteCount: 2,
|
||||||
|
trustedMuterDisplayNames: ["Carol", "Dave"],
|
||||||
trustedMuteDisplayNames: ["Carol", "Dave"],
|
trustedMuteDisplayNames: ["Carol", "Dave"],
|
||||||
|
original: { blurThumbnail: true, blurReason: "trusted-mute" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"contrast": "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 truncated
|
"contrast": "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 truncated
|
||||||
"default": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOzdB6BbZd0H4OR200VLy6a07Cmr7I1+CMiWyhYEFQcoIohMEQQBEQRFZMiQpaCAypa9994toy2rdALdK997b27T2zuT3JPknOR5rCXNOEnOePP+zv8953Tt2qNvCqCE0imoEpkUNBdRE1c/mXSB0yr0rdPpOLXHmfrtqdBtKt/nZxa8QefY5KEK1XXp1uFzuqYAAACABnUpAACqXtyG9ZTy8xjCBHSGkAwAQPWRlIEiCckAANCSmA01yjHJAAAA0EhIBgAAgEZCMkAMlGFMn0uZRCBtPgJA1ROSAQAAoJGQDABQnCgHgThJFEBMCMkAlVD+7nD+72hAMVRAyRsFIRwgT0IyUFJ6ZYsyPxLPYckAUOWEZAAAAGgkJAOURYJqyC0/qtLpIhSTISq2JiCOhGQAAABoJCQDpVMzB+BG/UXLNuPyreCkO/l6AIDEqEsBAIVxBjYAqFoqyUBCVHsqqcj36+zRx+miXwkAEFMqyQAVlq6OPQA1V1tVTIbOs3cNiCOVZCCuZJBKyM31YrquzlMLACSfkAyUiIzbXLLmSJFpudmXrPLMbK8AFM/2A8SWkAzET7Xka/sJauCgZf18AKg2QjJQCjUaD6vya2e/lCAIANQIIRmImXQRjxRA2CtOp45VbjaJ8ivtUldMptMMOykFmyZQLCEZiJN0nvdF+w4UoNUZqCMKFEq7AcSWS0AB0Flx3/VQJVfZgk4SSwHyopIMRK7YOCLGUFIRDBkHAKqfkAzEl9RMQji7GRTBJgPElJAMQASq/2JPUDqZcuwUzNTankctEVAsxyQDAFRceSKd4AjQMSEZiAdDqwEKU2uJt9Dva48AUCQhGQCgBsQtM8qwQFw5JhkAgDITkYH4UkkGAKgJ8QmmhX8SoRooHyEZiAf9H4CCJbbp1OYDMSYkA5HT96HWZKz2JEOm4X8AtMsxyUB8ZbtyznudCPrd0EkhvqZr43LJmUzJG4yI9gVo2KBGCclAbLTRdct1UiLp15W0yyPPx5ruLtGLQeIsXH1GTacr8rkzDW+fAog3w62BOEl43ymz6J/aUdt9Xj1+IlTE6lTUGthQzC3nupvpVEJ2hWSgrFSSgZhpuzCj10PHyr2WWCuJXLmq0/V5NV2WEd7Zt0uVlU0T6AQhGSiFznXyEjmAsRV5dtKS9V31PKEkGtq9amn82lKe9kMrBXSWkAyUiJycr0Scn6yzvc4q7LXqiFMFytT8dO40WgW/1sYJdJKQDMRVs25O53txpes3RdHDzJRkqsWLZm5VbV9VJ5yciHfpFTW5TnyGhjHXJW9vyrjFRPdWNnOoXUIykBCd7MWVtLeTz8SL6vaWYKpFvlF5JwQ1aUErV+aRNPGOyIW9NlPMiwCaE5KB0om665Xofk/khfHWploxtdUl1QGnZBpbzSIaz04VkzMlPIFXptyXfLKBAp0mJAMlVeaKSHK0341L0DwTjyHahq74M3h1Pienoh463rmDkYsrI0fDxg41TUgGSk1OLlyh3bN4jbquSuYO5VaBnJzKXhMqq1PHtzRcEjnVOcUNtLapAhEQkoEySMT5m5NMt7C0zF86FGkrt2BiFdjFmCv9FlVY7nTpuHOcXwGIiJAMlI2oTILoJVOECFNt/QDoonJyRJ+hMe1mUo1ZuZ1pRlI3bvH2hT47sg9h2weEZKDcjL4m/vSSKVpMjk+OTmNWzqXlRR+JwbYSk48BVBMhGSg/JWViRf+aeCo6HZcmVmdyf5VaAW/SJCHbkIHICMlApbTs0IjNlI4ONGUT/aDrYl6Y1Ba1wE21osO8gWolJAPxUZpLCRfwjuVRs/sC9D6pHZHm5FS62DNdp2rmgnKORgaiJCQDsaW/En+WEZReJpNKG2jTikyz/0YyMYBUqi4FQFllqqU3pk8J8ZeU7VR7AsSIkAxQEXqEQAE60WTEf8dcxQdaAyzCcGuASsn17RI3kFKvFMqs8wc5x/NUXsU3JpFe+UmbBixCSAaouKb9s7j1YvUdoTiRXzC584cmx+1UXp1vXjRQQEkIyQCxkn+fL5Keri4mlE4Mc3IqHiXlzrY8mUjryADNCMkACaWDCDWmSnJypxNy7q9oaEuB5py4CwCgRKIOYA0V1CgmWpGzeWWiScgZsRYoLZVkAIDSibps2zC9iCZantMHRnp+rYgDsrwNtEJIBgBIlEzDwOt0tEc850Q11SjzZ+O0Iq4hS8hA64RkAICSKsUxwJn6/zUcnhz1pDtTXi5J7MykSlJEBmiLkAwAUGqlOVdWw2m8SnYarrZCabrdR0ugJAlZ5Aba5MRdAABlUJpUlknk+bcKejMJGSgzIRkAoDxKlZMz1Rj7Mtk9ABkJGSg3IRkAoGxKV0+uqisjLbjUk0ALVIBjkgEAki8bK0tyKq+yKs2JrJtPHqAdKskAAOVUygJpJlPiNygtCRmIA5VkAIAqkk2YJTzrdUmUOB6nJGQgf0IyAED5lTjDZkvKSRh9Xfp4nJKQgYIIyQAAFZFNbiWOyulUJhXfqLzgBF0lfxOA/AnJAADVK7Pg/3GqKpelerzIWwHkT0gGAKig0teTG98nG5Ub36siaTnT9L/lSK8SMlAMIRkAoOLKdZqtbGE5nY2P5UvLC+Nx+XKrhAwUSUgGAIiDMp6OOrPwP9naconeuLFkXIG4KiEDxROSAQBiohKXbWp67qwmpeUiPkem6c1KplQJGegUIRkAID7KdYhym2/epMq8yKdIt/WCtv5VCeIxEAEhGQAgbjIVy8mpRT9FG/+IJQkZiEZdCgCA2BH5CmJ2AZFRSQYAiKeKDr1OEgkZiJKQDAAQZ/EYeh1T4jEQPSEZACDmlJRbEo+BUnFMMgBAIoiFOWYFUEIqyQAASaGkLB4DJSckAwAkSy4o1lRaFo+BMhGSAQASqkbO6SUeA2UlJAMAJFd1D8AWj4EKEJIBAJKu+gZgi8dAxQjJAABVowoKy+IxUGFCMgBAlUliYVk2BuJCSAYAqFbxT8uyMRA7QjIAQNWLVVoWjIFYE5IBAGpH04BatsAsFQNJIiQDANSmZtk12swsGANJJSQDAJASawGyhGQAAABoVLaQXOgAHvsyAQAAKLfSheROHtbS1suFZwAAAEolwpBcnhMkZt9FVAYAACB6nQ/JFbnanqgMAABA9IoIyXG4Bn1W7pNIywAAAESgoJAcn3jcTFpOBgAAoPO6xjj6FkRVGQAAgM4q43WSy3QRKFVlAAAAilTKkBz5RaDyDb9O6wUAAEAxShOS84vHrT4r0+ELCojKcjIAAAAFiC4kpyN5SpvPybR8RscRWE4GAACgAFGE5HRBd3f2TZqn5UzBrwMAAIBWFRuSyxKM23mLRaJyXvlXVRkAAIAOFBKS00U8UiqLFIjzPVbZCb0AAABoT0chOd3Jx0tuYYFYSRkAAIDOKeN1kktmYYFYSRkAAIBO6FpcLbjiBeSWlJQBAADopLpUgdKxTMhZ6VZudfiK2H4bAAAAym3hcOvqCIuLDL3Ot06spAwAAEC9unQ1llPTi/yngFcAAABQywoebp0wcjIAAAB5q9qQnFZPBgAAoEDVcAmojhVwySdXhwIAAKhdVT7cOl3MKa9TSsoAAAC1qdqPSW4gJwMAAJCPmgjJKTkZAACAPNRKSE41y8n55l85GQAAoIbUUEhOFRl55WQAAIBaUVshOeXSUAAAALSt5kJylpwMAABASzUakhcSfgEAAFigdkNy4ee7lqcBAACqXNdUDQupN9P8Vr6voIXidiKYnwAAQIzUdEhOFZN65eSmOl9dbzoFMxYAAKiwWg/JqVzqLSD81nJOLumYc4E5Vjpc1pYRAABVSEhuQpG4dRU5GDv3phZJeRSxlFt9ieUFAECyCcn1HJzctoqfrkxaLp1SLFzDAQAASDYhuZGDkxcVw1N5S8tRKdvCtcgAAEgeIXkhByc3iP+VrrKfUO4qVAWXrEUGAEBiVCokF9RfL3vfukYPTk7WhaDlrvzFZMlaZAAAJEA5Q3LRPfXyHeVYq4OukxWPm3KytfbFdti8pQYAQEyVOiRH3kcveQ+78EHXiZbceJwjdLUq5kvWUgMAIKZKEZLL0DuPVQ87oXm6CuJxU0JXToKWrKUGAEDs1KWilC5vB73Eb1fAtBMXOKssIedU6/fKXxLnQNqCAwAgPjpfSY7JdXSjLEZV+1Dr6g4kNXuUctIXq6oyAACxUFxIrv6L6Fbvkcm1ULKrwbhVNYvVmdgAAKiwgkJyIjrilepkx79zX2sjWmsnbjm8HAAAIpNPSE7oAbcRdbKrJGrV5jGftZCTq/jwcjm5/NpfnSwRAKAmtBOSq+AQx0516aromsm1fFak6s5aDi+naEWsPK2+xDICAKpN16ruZ5c/J8eQ8wZXa9aqkcPLZbAIlWKdifh8EAAAFVeK6yTHShRDrwvoqMetTy8hZ1Vf1qqdJesQ5c4rz9rS9F0sLwAgwao+JGcVn5EKP811fPJYRXNUO2+u/9xZNbjvQ0m5CBVcT5SXAYAEq5GQnKq9TnZ5+8cFvVvLJ5djyVRNQbJmRwfIyQWJyXpiIAAAkDy1E5JTZexkV7w3X67+cbqguxtl2npBmaJyovvrNT5+Xk7OR2yvY2/ZAQDJUFMhOVVcJ7vwEdcVVJl43JlCcqbZAzrSbYrf+PkKLCw5uS3x34FiDDYAkAy1FpJT5epkV2lXPt3hHUVOsoxROaGLpowRKP+3anOHB+WUuPEF9nQAALFWgyE55ZpAUU0+wvdrPSqnSrSg9NHbkM7zvkaZVp9a8llr8TWV0BH4xo0AAPFVmyE5VWg/u6heeZm78qXsK5cyHrecbKbZXTrS5QlChQ+hb/2aP+WIP1aLrIQm5BzLEQCIo5oNyeQt3erNPJ7dnkyHk1gkdEXfkdY7b6KwRdzBNDJN/23YPB1QUgYAYqeWQ3IZOtnlPJ92aSccXTxu9uRM+Line truncated
|
"default": "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 truncated
|
||||||
"light": "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 truncated
|
"light": "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 truncated
|
||||||
"video-modal-mobile": "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 truncated
|
"video-modal-mobile": "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 truncated
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
# TODO: innerHTML Migration for ShareNostrModal.js
|
# TODO: innerHTML Migration for ShareNostrModal.js
|
||||||
|
|
||||||
- [ ] L107: `wrapper.innerHTML = html;` (Template loading)
|
- [x] L107: `wrapper.innerHTML = html;` (Template loading)
|
||||||
- Strategy: Use `new DOMParser().parseFromString(html, 'text/html')` and append children to `wrapper`.
|
- Strategy: Use `new DOMParser().parseFromString(html, 'text/html')` and append children to `wrapper`.
|
||||||
- [ ] L274: `this.relayPills.innerHTML = "";` (Clearing content)
|
- [x] L274: `this.relayPills.innerHTML = "";` (Clearing content)
|
||||||
- Strategy: `this.relayPills.replaceChildren();` or `textContent = ""` (replaceChildren is preferred for performance).
|
- Strategy: `this.relayPills.replaceChildren();` or `textContent = ""` (replaceChildren is preferred for performance).
|
||||||
- [ ] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
|
- [x] L300: `removeButton.innerHTML = '<svg ...>...</svg>';` (SVG Icon)
|
||||||
- Strategy: Create SVG elements using `document.createElementNS("http://www.w3.org/2000/svg", "svg")` and children.
|
- Strategy: Create SVG elements using `document.createElementNS("http://www.w3.org/2000/svg", "svg")` and children.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**CLOSED 2026-07-30.** All three migrations already landed in later refactors —
|
||||||
|
`grep innerHTML js/ui/components/ShareNostrModal.js` returns nothing and the
|
||||||
|
innerHTML lint baseline holds. Kept for the record.
|
||||||
@@ -103,13 +103,31 @@ Likely a shared root cause in session/identity persistence + multi-account handl
|
|||||||
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
|
`SignerManager.logout` (previousPubkey/hadRemoteClient), and
|
||||||
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
|
`authService.switchProfile` (from/to + whether requestLogin resolved the expected
|
||||||
pubkey). **Remove these once the fix lands.**
|
pubkey). **Remove these once the fix lands.**
|
||||||
|
- [x] **CODE-LEVEL SUSPECTS RE-AUDITED + PINNED (2026-07-30).** Every root cause the
|
||||||
|
2026-06-25 audit named has since been fixed by later work, verified end-to-end
|
||||||
|
by reading the current code:
|
||||||
|
- Per-account NIP-46 session storage exists (`bitvid:nip46:sessions:v2` map,
|
||||||
|
v1 slot = last-connected default, migration on read).
|
||||||
|
- `logout()` → `disconnectRemoteSigner({ keepStored: true })` — stored
|
||||||
|
sessions survive; the no-arg `clearStoredNip46Session()` clears ONLY the
|
||||||
|
v1 default slot, never the map (the wipe-all was fixed earlier).
|
||||||
|
- Switch is fully wired: coordinator sets `reuseStored: true` for nip46
|
||||||
|
targets → `switchProfile` passes `expectPubkey` → provider forwards it →
|
||||||
|
`useStoredRemoteSigner({ pubkey })` restores the TARGET account's session,
|
||||||
|
and forgets only the attempted account on access-denial.
|
||||||
|
- Boot restore (`scheduleStoredRemoteSignerRestore`) enforces the
|
||||||
|
access-control validator and fails scoped (nothing else cleared).
|
||||||
|
NEW: `tests/nip46-signer-lifecycle.test.mjs` pins the manager-level contract
|
||||||
|
(logout scoping, restore-failure scoping, bare-disconnect scoping) — mutation-
|
||||||
|
checked: reinjecting the historical keepStored/wipe-all bugs fails 2/6.
|
||||||
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
|
- [ ] **VERIFY / REPRODUCE (user action):** on the phone with Amber on
|
||||||
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
|
unstable.bitvid.network, exercise (1) fresh load, (2) switch profiles, (3) logout,
|
||||||
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
|
and capture the `[nip46-diag]` console lines for each — restore succeeded/FAILED,
|
||||||
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
|
switchProfile `matchesTarget`, and disconnect `keepStored`/`hadClient`/caller stack.
|
||||||
- [ ] **THEN fix** based on the captured trace (likely: per-profile NIP-46 session keyed
|
If all three behave, close this item and REMOVE the `[nip46-diag]` diagnostics;
|
||||||
by pubkey; don't tear the shared client on a scoped logout; reconnect on
|
if not, the trace now points at whatever the unit contract does NOT cover
|
||||||
switch/restore) and REMOVE the temporary `[nip46-diag]` diagnostics.
|
(likely UI/state reconciliation, e.g. restore-failure leaving "unknown user"
|
||||||
|
rendered instead of a reconnect prompt).
|
||||||
|
|
||||||
### 1. Delete is not fully working — tombstoned videos still show in the UI
|
### 1. Delete is not fully working — tombstoned videos still show in the UI
|
||||||
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
|
- [x] **Root cause found + fixed** (`afb6200b`): deletes published only to the CAPPED
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Static asset caching on Vercel — 2026-07-31
|
||||||
|
|
||||||
|
Carried over from the BitUnlock/BitLogin deployment work. Findings below were
|
||||||
|
verified against the live site on 2026-07-31, not assumed.
|
||||||
|
|
||||||
|
## What is actually happening
|
||||||
|
|
||||||
|
`vercel.json` declares exactly three header rules — `/sw.min.js`, `/`, and
|
||||||
|
`/(.*\.html)`. Nothing covers `/css/*`, `/js/*`, `/vendor/*`, or `/assets/*`,
|
||||||
|
so those fall through to Vercel's default. Measured live:
|
||||||
|
|
||||||
|
| Asset | Cache-Control |
|
||||||
|
| ---------------------------- | ------------------------------------------ |
|
||||||
|
| `css/tailwind.generated.css` | `public, max-age=0, must-revalidate` |
|
||||||
|
| `js/app.js` | `public, max-age=0, must-revalidate` |
|
||||||
|
| `index.html` | `no-cache, must-revalidate` |
|
||||||
|
|
||||||
|
Every CSS and JS file revalidates on **every** page load. The 304s are cheap
|
||||||
|
but they are still round-trips, and they are on the critical render path.
|
||||||
|
|
||||||
|
## The trap: do NOT just add `immutable`
|
||||||
|
|
||||||
|
The obvious fix — port the old `immutable` + 1-year rules across — would be a
|
||||||
|
**regression**, not an improvement. Asset URLs in `index.html` are not
|
||||||
|
content-hashed:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link href="css/tailwind.generated.css" />
|
||||||
|
<script src="js/index.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
A long `max-age` with `immutable` on an unhashed filename tells every browser
|
||||||
|
to keep that exact file for a year and never revalidate. The next deploy
|
||||||
|
changes the file's contents but not its URL, so returning users are pinned to
|
||||||
|
stale CSS/JS with no way to bust it short of a hard reload — and `immutable`
|
||||||
|
specifically instructs the browser to skip revalidation even on reload in some
|
||||||
|
engines. `scripts/hash-dist.mjs` exists but writes a hash *manifest* for
|
||||||
|
deploy verification; it does not rename assets or rewrite references.
|
||||||
|
|
||||||
|
## Two ways forward
|
||||||
|
|
||||||
|
- [ ] **Option A (correct, more work): content-hash the asset filenames.**
|
||||||
|
Emit `css/tailwind.<hash>.css` / `js/index.<hash>.js` from the build and
|
||||||
|
rewrite references in the emitted HTML. Only then add
|
||||||
|
`public, max-age=31536000, immutable` for `/css/*`, `/js/*`, `/assets/*`.
|
||||||
|
This is the only combination where `immutable` is safe, because the URL
|
||||||
|
changes whenever the bytes do.
|
||||||
|
- [ ] **Option B (safe now, smaller win): a bounded max-age, no `immutable`.**
|
||||||
|
e.g. `public, max-age=600, stale-while-revalidate=86400` for `/css/*` and
|
||||||
|
`/js/*`. Repeat views inside the window skip the round-trip entirely,
|
||||||
|
a deploy is picked up within ten minutes, and nothing can pin a user to a
|
||||||
|
stale bundle. Choose this if hashing is not happening soon.
|
||||||
|
|
||||||
|
**DECIDED 2026-07-31: Option A.** Content-hash the filenames, then apply
|
||||||
|
`immutable`. Do not ship Option B as an interim step — the two are not
|
||||||
|
stackable, and a bounded `max-age` shipped now becomes the thing nobody
|
||||||
|
revisits.
|
||||||
|
|
||||||
|
## Also worth doing
|
||||||
|
|
||||||
|
- [ ] Cover `/vendor/*` in whichever option is chosen — those files
|
||||||
|
(`marked.min.js`, `highlight.min.js`) are third-party and genuinely
|
||||||
|
change rarely, so they benefit most.
|
||||||
|
- [ ] Confirm `/sw.min.js` keeps a short/no-cache policy under any change. A
|
||||||
|
cached service worker is the one asset that can persist a bad deploy
|
||||||
|
past a user's next visit.
|
||||||
|
- [ ] Re-measure with `curl -sI` after deploying. Vercel's dashboard settings
|
||||||
|
can override `vercel.json`, so the header on the wire is the only
|
||||||
|
source of truth.
|
||||||
|
|
||||||
|
## Corrected note
|
||||||
|
|
||||||
|
An earlier hand-off claimed bitvid had "~10 pre-existing test failures". That
|
||||||
|
is **not true** as of 2026-07-31 — `npm run test:unit` (13/13),
|
||||||
|
`npm run test:dm:unit` (2/2 and 4/4), and `npm run test:smoke` all pass on
|
||||||
|
`unstable`. Do not spend time chasing failures that are not there.
|
||||||
+26
-7
@@ -1,18 +1,37 @@
|
|||||||
{
|
{
|
||||||
"package": "@bitlogin/widget",
|
"package": "@bitlogin/widget",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"source": {
|
||||||
|
"repository": "https://github.com/PR0M3TH3AN/bitlogin.git",
|
||||||
|
"commit": "205b0678cc67ecbde18278b8f46e7f8270fb519c",
|
||||||
|
"commitDate": "2026-07-31T22:04:01-04:00",
|
||||||
|
"dirty": false,
|
||||||
|
"provenance": "attributed",
|
||||||
|
"toolchain": {
|
||||||
|
"node": "v22.23.1",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"bitlogin-shared-QIBe5Omw.js": {
|
"bitlogin-shared--Zs4uvXt.js": {
|
||||||
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
|
"sha256": "550434b2ed3df975fc1d8896dca02f8622f2819d1fd9fa288049f2c22baa6bc4",
|
||||||
"bytes": 288275
|
"bytes": 483718
|
||||||
|
},
|
||||||
|
"bitlogin-shared-BxVSgyOf.js": {
|
||||||
|
"sha256": "d7f157c2de3d4b81de0055df2582306df9d862706d1dad35ff4096d06e1c1c93",
|
||||||
|
"bytes": 545712
|
||||||
|
},
|
||||||
|
"bitlogin-shared-DTz11m56.js": {
|
||||||
|
"sha256": "c52f485c6390658e8da75d7c31f01167eb4f8c22d836f9d92e22c2b0885bf79e",
|
||||||
|
"bytes": 289118
|
||||||
},
|
},
|
||||||
"bitlogin.js": {
|
"bitlogin.js": {
|
||||||
"sha256": "55312cef3462a2e41043b57bd3c87a80bad4e077dd6e529faf25fa55db4ba7a8",
|
"sha256": "56eeb7584b0117e2e360c80d37a5bfddb2e1f7a8ee6d94995c24043c0e87b906",
|
||||||
"bytes": 67232
|
"bytes": 77303
|
||||||
},
|
},
|
||||||
"cryptoWorker.js": {
|
"cryptoWorker.js": {
|
||||||
"sha256": "49a421e1026ad94243ceb0653f3761820d4e98e19b8fbe469307cd692f9a8489",
|
"sha256": "4d569dac3b808d118bf432d628f084c8c1d8ba76d94cf101030ace1077c63b36",
|
||||||
"bytes": 79173
|
"bytes": 110827
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8269
File diff suppressed because it is too large.
Load diff
+11772
File diff suppressed because it is too large.
Load diff
+494
-472
File diff suppressed because it is too large.
Load diff
Vendored
+507
-248
File diff suppressed because it is too large.
Load diff
Vendored
+1795
-899
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user