Files
bitvid/tests/onboarding-tour.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 4591d4a260 [onboarding] Fix popover pushed off-screen by full-height targets (sidebar step)
Step 2 (sidebar) left the user "stuck looking at a glow with nothing to do": the
sidebar is a full-height column, so the below/above-only placement computed a
popover position past the bottom of the viewport — the card existed but was
invisible off-screen.

Placement is now a pure, exported decision (computeTourPlacement): try below →
above → beside-right → beside-left, whichever actually fits the popover, with a
centered-over-the-dim fallback when nothing does (viewport-filling targets) — and
coordinates are clamped so the card can NEVER leave the viewport. The sidebar
step also hints placement: "right". A preferred placement that doesn't fit falls
back to one that does.

Tests: +1 in tests/onboarding-tour.test.mjs — full-height column → beside-right
inside the viewport; button → bottom; near-bottom target → top; viewport-filling
target → centered; unfittable preference falls back and never goes below the
fold. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:50:46 -04:00

233 lines
8.5 KiB
JavaScript

// First-run onboarding (docs/onboarding-plan.md): the per-pubkey once flag and
// the tour engine's lifecycle (spotlight + popover + next/skip + auto-skipping
// steps whose anchor is missing), driven against real JSDOM.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-onboarding-first-run
// given: "a fresh pubkey / a completed pubkey; a DOM with some tour anchors"
// when: "shouldOfferOnboarding + launchBitvidTour/createTour run"
// then: "offered once; completing/skipping records the flag; steps with missing anchors are skipped; force reruns"
// observable_outcomes:
// - "fresh pubkey -> offer true; completed/skipped -> offer false; force -> starts anyway"
// - "tour renders overlay + popover; Next advances; Done finishes -> completed flag"
// - "Skip records skipped and removes the overlay"
// - "steps with absent targets are excluded from the dot count"
// determinism_controls:
// - "JSDOM; in-memory localStorage polyfill; no timers beyond direct calls"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// 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 { JSDOM } from "jsdom";
import {
shouldOfferOnboarding,
markOnboarding,
resetOnboarding,
} from "../js/services/onboardingService.js";
import { createTour } from "../js/ui/onboarding/tourEngine.js";
import { launchBitvidTour } from "../js/ui/onboarding/bitvidTour.js";
const PUB = "a".repeat(64);
beforeEach(() => {
localStorage.clear();
});
test("flag: offered when fresh; not after completed/skipped; reset restores", () => {
assert.equal(shouldOfferOnboarding(PUB), true);
markOnboarding(PUB, "completed");
assert.equal(shouldOfferOnboarding(PUB), false);
resetOnboarding(PUB);
assert.equal(shouldOfferOnboarding(PUB), true);
markOnboarding(PUB, "skipped");
assert.equal(shouldOfferOnboarding(PUB), false);
assert.equal(shouldOfferOnboarding(""), false, "no pubkey never offers");
});
function makeDom() {
const dom = new JSDOM(
'<!DOCTYPE html><body><nav id="sidebar"></nav><button data-testid="upload-button"></button></body>',
{ pretendToBeVisual: true },
);
return dom.window.document;
}
test("tour lifecycle: renders, advances, Done records completion", () => {
const doc = makeDom();
let finished = false;
const tour = createTour({
document: doc,
steps: [
{ id: "welcome", title: "Hi", body: "b" },
{ id: "feeds", target: "#sidebar", title: "Feeds", body: "b" },
{ id: "gone", target: "#does-not-exist", title: "Missing", body: "b" },
{ id: "upload", target: '[data-testid="upload-button"]', title: "Up", body: "b" },
],
onFinish: () => {
finished = true;
},
});
assert.equal(tour.start(), true);
const popover = doc.querySelector(".bv-tour-popover");
assert.ok(popover, "popover rendered");
assert.ok(doc.querySelector(".bv-tour-scrim"), "spotlight scrim rendered");
assert.equal(
doc.querySelectorAll(".bv-tour-dots span").length,
3,
"missing-anchor step excluded from progress dots",
);
assert.match(popover.querySelector("h3").textContent, /Hi/);
// Advance through all available steps via the primary button.
const clickPrimary = () => {
const buttons = Array.from(popover.querySelectorAll("button"));
buttons[buttons.length - 1].click();
};
clickPrimary(); // -> feeds
assert.match(popover.querySelector("h3").textContent, /Feeds/);
clickPrimary(); // -> upload (skips missing)
assert.match(popover.querySelector("h3").textContent, /Up/);
clickPrimary(); // Done
assert.equal(finished, true, "onFinish fired");
assert.equal(doc.querySelector(".bv-tour-root"), null, "overlay removed");
});
test("Skip records skipped and tears down", () => {
const doc = makeDom();
let skipped = false;
const tour = createTour({
document: doc,
steps: [{ id: "welcome", title: "Hi", body: "b" }],
onSkip: () => {
skipped = true;
},
});
tour.start();
doc.querySelector(".bv-tour-skip").click();
assert.equal(skipped, true);
assert.equal(doc.querySelector(".bv-tour-root"), null);
});
test("launchBitvidTour: offers once per pubkey, force reruns, records the flag", () => {
const doc = makeDom();
assert.equal(
launchBitvidTour({ pubkey: PUB, document: doc }),
true,
"fresh pubkey starts the tour",
);
// Skip it → flag recorded.
doc.querySelector(".bv-tour-skip").click();
assert.equal(shouldOfferOnboarding(PUB), false, "skip recorded");
assert.equal(
launchBitvidTour({ pubkey: PUB, document: doc }),
false,
"not offered again automatically",
);
assert.equal(
launchBitvidTour({ pubkey: PUB, document: doc, force: true }),
true,
"force ('Take the tour') reruns it",
);
doc.querySelector(".bv-tour-skip").click();
});
test("final-card action buttons deep-link and complete the tour", () => {
const doc = makeDom();
const panes = [];
launchBitvidTour({
pubkey: PUB,
document: doc,
openProfilePane: (pane) => panes.push(pane),
});
// Jump to the last step by clicking primary until the actions appear.
const popover = doc.querySelector(".bv-tour-popover");
for (let i = 0; i < 10 && doc.querySelector(".bv-tour-root"); i += 1) {
const buttons = Array.from(popover.querySelectorAll("button"));
const storage = buttons.find((b) => /Set up storage/.test(b.textContent));
if (storage) {
storage.click();
break;
}
buttons[buttons.length - 1].click();
}
assert.deepEqual(panes, ["storage"], "deep-linked into the storage pane");
assert.equal(doc.querySelector(".bv-tour-root"), null, "tour closed");
assert.equal(shouldOfferOnboarding(PUB), false, "completion recorded");
});
// Regression (the "stuck at the sidebar" bug): the popover placement must NEVER
// land outside the viewport. A full-height target (sidebar) used to push the
// popover below the fold, leaving the user with a glowing sidebar and no card.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-tour-placement
// given: "target rects of various shapes vs a viewport"
// when: "computeTourPlacement decides where the popover goes"
// then: "it picks a side that fits and always stays inside the viewport"
// observable_outcomes:
// - "full-height left column -> 'right' placement, y within viewport"
// - "normal button with room below -> 'bottom'"
// - "target near the bottom edge with room above -> 'top'"
// - "viewport-filling target -> 'center' fallback (always visible)"
// determinism_controls:
// - "pure function, explicit rect/viewport inputs"
// anti_cheat_rationale:
// prevents: ["asserting the off-screen (broken) coordinates"]
// relaxation:
// did_relax_any_assertion: false
test("placement: full-height sidebar goes BESIDE, never off-screen", async () => {
const { computeTourPlacement } = await import("../js/ui/onboarding/tourEngine.js");
const viewport = { viewportWidth: 1280, viewportHeight: 800 };
const sidebar = computeTourPlacement({
rect: { left: 0, top: 64, right: 240, bottom: 800, width: 240, height: 736 },
...viewport,
});
assert.equal(sidebar.placement, "right");
assert.ok(sidebar.py > 0 && sidebar.py < 800, "y inside the viewport");
assert.ok(sidebar.px > 240 && sidebar.px < 1280, "x beside the sidebar");
const button = computeTourPlacement({
rect: { left: 600, top: 100, right: 700, bottom: 140, width: 100, height: 40 },
...viewport,
});
assert.equal(button.placement, "bottom");
const nearBottom = computeTourPlacement({
rect: { left: 600, top: 700, right: 700, bottom: 780, width: 100, height: 80 },
...viewport,
});
assert.equal(nearBottom.placement, "top");
const fullScreen = computeTourPlacement({
rect: { left: 0, top: 0, right: 1280, bottom: 800, width: 1280, height: 800 },
...viewport,
});
assert.equal(fullScreen.placement, "center");
assert.equal(fullScreen.px, 640);
assert.equal(fullScreen.py, 400);
// A preferred placement that doesn't fit falls back to one that does.
const badPreference = computeTourPlacement({
rect: { left: 0, top: 64, right: 240, bottom: 800, width: 240, height: 736 },
preferred: "bottom",
...viewport,
});
assert.notEqual(badPreference.placement, "bottom");
assert.ok(badPreference.py <= 800 - 12, "never below the fold");
});