fix(ui): avoid missing selector in scan tour (#12705)

This commit is contained in:
Alejandro Bailo
2026-09-03 10:23:24 +02:00
committed by GitHub
parent 36514534cb
commit 8007501574
10 changed files with 214 additions and 24 deletions
@@ -0,0 +1 @@
Scan Jobs onboarding tour no longer targets an unmounted In Progress row from other tabs
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OnboardingFlow } from "@/lib/onboarding";
import { useScansStore } from "@/store";
import { ProviderProps } from "@/types";
@@ -115,6 +116,18 @@ vi.mock("@/components/onboarding", () => ({
PageReady: () => <div data-testid="page-ready" />,
}));
interface OnboardingTriggerProps {
flow: OnboardingFlow;
}
const getTriggeredTourTargets = () => {
const triggerProps = onboardingTriggerSpy.mock.calls.at(-1)?.[0] as
| OnboardingTriggerProps
| undefined;
return triggerProps?.flow.tour.steps.map((step) => step.target);
};
const providers: ProviderProps[] = [
{
id: "provider-1",
@@ -594,6 +607,50 @@ describe("ScansPageShell", () => {
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
it("uses only mounted tour targets when an active scan exists on the completed tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([undefined, "launch", "tabs"]);
});
it("targets the running scan when its row is mounted on the in progress tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([
undefined,
"in-progress",
"launch",
]);
});
it("suppresses the view-first-scan tour when no provider is connected, since Launch Scan is disabled", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
+5 -4
View File
@@ -80,9 +80,10 @@ export function ScansPageShell({
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen =
!launchDisabled && (isLaunchScanModalOpen || urlLaunchOpen);
// When a scan is already running, the tour highlights its row (anchored in
// ScanJobsTable); otherwise it falls back to the Launch Scan button + tabs.
const hasInProgressScan = activeScanCount > 0;
// ScanJobsTable only mounts the in-progress row anchor on the active tab.
// Other tabs use the fallback tour so every target exists in the current DOM.
const hasVisibleInProgressScan =
activeScanCount > 0 && filters.activeTab === SCAN_JOBS_TAB.ACTIVE;
const getTabLabel = (tab: ScanJobsTab) => {
const label = SCAN_TAB_LABELS[tab];
@@ -122,7 +123,7 @@ export function ScansPageShell({
<OnboardingTrigger
flow={{
...viewFirstScanFlow,
tour: buildViewFirstScanTour(hasInProgressScan),
tour: buildViewFirstScanTour(hasVisibleInProgressScan),
}}
/>
</Suspense>
@@ -1,5 +1,5 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { addProviderTour } from "../add-provider.tour";
import type { TourCompletionRecord } from "../tour-types";
@@ -94,3 +94,64 @@ describe("adaptStep autoAdvance", () => {
expect(driveStep.popover?.showButtons).toBeUndefined();
});
});
describe("adaptStep selector fallback", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("uses the fallback target when the primary target disappears", () => {
// Given
const fallbackElement = document.createElement("div");
fallbackElement.dataset.tourId = "view-first-scan-tabs";
document.body.append(fallbackElement);
const driveStep = adaptStep("view-first-scan", {
target: "in-progress",
fallbackTarget: "tabs",
title: "Your scan is running",
});
// When
const resolvedElement = (driveStep.element as () => Element)();
// Then
expect(resolvedElement).toBe(fallbackElement);
});
it("keeps the primary target when both targets exist", () => {
// Given
const primaryElement = document.createElement("div");
primaryElement.dataset.tourId = "view-first-scan-in-progress";
const fallbackElement = document.createElement("div");
fallbackElement.dataset.tourId = "view-first-scan-tabs";
document.body.append(primaryElement, fallbackElement);
const driveStep = adaptStep("view-first-scan", {
target: "in-progress",
fallbackTarget: "tabs",
title: "Your scan is running",
});
// When
const resolvedElement = (driveStep.element as () => Element)();
// Then
expect(resolvedElement).toBe(primaryElement);
});
it("still reports configuration drift when both targets are missing", () => {
// Given
const driveStep = adaptStep("view-first-scan", {
target: "in-progress",
fallbackTarget: "tabs",
title: "Your scan is running",
});
// When
const resolveElement = () => (driveStep.element as () => Element)();
// Then
expect(resolveElement).toThrow(
'Tour "view-first-scan" references missing selector: [data-tour-id="view-first-scan-in-progress"]',
);
});
});
@@ -72,6 +72,19 @@ describe("buildViewFirstScanTour with a running scan", () => {
expect(tour.version).toBe(viewFirstScanTour.version);
});
it("falls back to the stable tabs anchor if the running row disappears", () => {
// Given
const runningScanStep = tour.steps.find(
(step) => step.target === "in-progress",
);
// When
const fallbackTarget = runningScanStep?.fallbackTarget;
// Then
expect(fallbackTarget).toBe("tabs");
});
it("never targets an element outside the allowed anchor set", () => {
for (const target of definedTargets(tour)) {
expect(ALLOWED_TARGETS).toContain(target);
+3
View File
@@ -45,6 +45,9 @@ export interface TourCompletionRecord {
// Modal step omits `target`; anchored step provides the `data-tour-id` value (no brackets).
export interface TourStep<TTarget extends string = string> {
target?: TTarget;
// Optional stable anchor used when a volatile primary target disappears before
// driver.js resolves the step (for example, a running scan row that completes).
fallbackTarget?: TTarget;
title?: string;
description?: string;
side?: TourStepSide;
+6 -1
View File
@@ -172,11 +172,16 @@ export function adaptStep<TTarget extends string>(
if (step.target) {
const selector = toSelector(`${tourId}-${step.target}`);
const fallbackSelector = step.fallbackTarget
? toSelector(`${tourId}-${step.fallbackTarget}`)
: undefined;
driveStep.element = () => {
if (typeof document === "undefined") {
throw new Error("Tour element resolved without a DOM");
}
const found = document.querySelector(selector);
const found =
document.querySelector(selector) ??
(fallbackSelector ? document.querySelector(fallbackSelector) : null);
if (!found) {
throw new Error(
`Tour "${tourId}" references missing selector: ${selector}`,
+3 -3
View File
@@ -33,9 +33,8 @@ const INTRO_STEP = {
/**
* Builds the tour for the scans page. When a scan is already running we anchor the
* In Progress row first (and mention the other tabs in copy); otherwise we fall back
* to highlighting Launch Scan and the tabs. Gating on `hasInProgressScan` keeps the
* tour from anchoring to a missing row — the same guard pattern the findings tour
* uses for an empty table.
* to highlighting Launch Scan and the tabs. The volatile row step itself falls back
* to the stable tabs anchor if the scan completes while the tour is open.
*/
export function buildViewFirstScanTour(
hasInProgressScan: boolean,
@@ -49,6 +48,7 @@ export function buildViewFirstScanTour(
INTRO_STEP,
{
target: "in-progress",
fallbackTarget: "tabs",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Your scan is running",
+23 -11
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
// Tour alignment check (syntactic). Extracts `data-tour-id` values from every
// `ui/lib/tours/*.tour.ts` and verifies a matching attribute exists under `ui/`,
// in either the JSX form (`data-tour-id="..."`) or the object-property form
// (`"data-tour-id": "..."`) used for dynamically-spread anchors. Two directions:
// - Tour → DOM: fails on any tour `target` with no matching attribute.
// Tour alignment check (syntactic). Extracts primary and fallback `data-tour-id`
// values from every `ui/lib/tours/*.tour.ts` and verifies a matching attribute
// exists under `ui/`, in either the JSX form (`data-tour-id="..."`) or the
// object-property form (`"data-tour-id": "..."`) used for dynamically-spread
// anchors. Two directions:
// - Tour → DOM: fails on any `target` or `fallbackTarget` without an attribute.
// - DOM → tour: warns on any `data-tour-id` not referenced by any tour
// (does not fail — staged anchors during multi-PR rollouts are OK).
// Complements the semantic `prowler-tour` skill for CI/local runs without
@@ -41,7 +42,18 @@ async function findTourFiles(dir) {
}
const TOUR_ID_PATTERN = /\bid\s*:\s*["']([a-z0-9-]+)["']/m;
const TARGET_PATTERN = /\btarget\s*:\s*["']([a-z0-9-]+)["']/g;
const TARGET_PATTERN =
/\b(?:target|fallbackTarget)\s*:\s*["']([a-z0-9-]+)["']/g;
/**
* Extracts every primary and fallback target declared by a tour.
*
* @param {string} source
* @returns {string[]}
*/
export function extractTourTargets(source) {
return Array.from(source.matchAll(TARGET_PATTERN), (match) => match[1]);
}
async function parseTour(filePath) {
const source = await readFile(filePath, "utf8");
@@ -53,10 +65,7 @@ async function parseTour(filePath) {
}
const tourId = idMatch[1];
const targets = [];
for (const match of source.matchAll(TARGET_PATTERN)) {
targets.push(match[1]);
}
const targets = extractTourTargets(source);
return {
file: relative(UI_DIR, filePath),
@@ -155,7 +164,7 @@ async function main() {
if (tourOrphans.length === 0) {
const referenced = tours.reduce((sum, t) => sum + t.selectors.length, 0);
console.log(
`✓ Tour alignment OK — ${tours.length} tour(s), ${referenced} anchored step(s).`,
`✓ Tour alignment OK — ${tours.length} tour(s), ${referenced} anchor reference(s).`,
);
return;
}
@@ -172,7 +181,10 @@ async function main() {
process.exit(1);
}
const entryPoint = process.argv[1];
if (entryPoint && resolve(entryPoint) === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { extractTourTargets } from "./check-tour-alignment.mjs";
describe("tour target extraction", () => {
it("should include primary and fallback targets", () => {
// Given
const source = `
{
target: "volatile-row",
fallbackTarget: "stable-tabs",
}
`;
// When
const targets = extractTourTargets(source);
// Then
expect(targets).toEqual(["volatile-row", "stable-tabs"]);
});
it("should include a target used only as a fallback", () => {
// Given
const source = `
{
fallbackTarget: "fallback-only-anchor",
title: "Fallback-only step",
}
`;
// When
const targets = extractTourTargets(source);
// Then
expect(targets).toEqual(["fallback-only-anchor"]);
});
});