// 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( '
', { 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"); });