Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/studio/e2e/onboarding-detection-loop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from "@playwright/test";
import { enterDemoMode } from "./helpers.js";

const DETECT_GLOB = "**/api/setup/detect";

async function openWizardToDetectStep(page: import("@playwright/test").Page) {
await enterDemoMode(page);
await page
.getByRole("button", { name: /Run setup wizard|Review setup wizard/ })
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Welcome", level: 1 }),
).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await expect(
page.getByRole("heading", { name: "Your site", level: 1 }),
).toBeVisible();
}

test.describe("Onboarding detect-site failure handling", () => {
test("failed detection does not refetch in a loop", async ({ page }) => {
let hits = 0;
await page.route(DETECT_GLOB, async (route) => {
hits += 1;
await route.fulfill({
status: 500,
contentType: "application/json",
body: "{}",
});
});

await openWizardToDetectStep(page);

// Sit on the detect-site step and measure the request rate.
const baseline = hits;
await page.waitForTimeout(3000);
const delta = hits - baseline;
expect(
delta,
`expected at most 1 detect request while idle on a failed scan, saw ${delta}`,
).toBeLessThanOrEqual(1);

// The failure must surface as a real error state, and Continue must be blocked.
await expect(page.getByText("Could not scan your project")).toBeVisible();
await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled();
});

test("retry triggers exactly one refetch and recovers on success", async ({
page,
}) => {
let hits = 0;
let mode: "fail" | "pass" = "fail";
await page.route(DETECT_GLOB, async (route) => {
hits += 1;
if (mode === "fail") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: "{}",
});
return;
}
await route.continue();
});

await openWizardToDetectStep(page);
await expect(page.getByText("Could not scan your project")).toBeVisible();

// Next call should reach the real backend and succeed.
const before = hits;
mode = "pass";
await page.getByRole("button", { name: "Try again" }).click();

// Recovery: error clears and a real detection result renders.
await expect(page.getByText("Could not scan your project")).toBeHidden();
const detected = page.getByText(/We found an? /);
const notDetected = page.getByText("We could not detect your site");
await expect(detected.or(notDetected)).toBeVisible({ timeout: 15_000 });

// Exactly one additional request was made by the retry.
expect(hits - before).toBe(1);
});
});
59 changes: 48 additions & 11 deletions apps/studio/src/components/OnboardingWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export function OnboardingWizard({
}: OnboardingWizardProps) {
const [step, setStep] = useState<OnboardingStepId>("welcome");
const [report, setReport] = useState<SetupDetectionReport | null>(null);
const [loadingDetection, setLoadingDetection] = useState(false);
const [detectionError, setDetectionError] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [resolvedAttempt, setResolvedAttempt] = useState(-1);
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedContentRoot, setSelectedContentRoot] = useState<string | null>(
null,
Expand All @@ -64,21 +66,47 @@ export function OnboardingWizard({
activeSuggestion?.contentDir ??
null;

// "attempted" is tracked by resolvedAttempt (set only when a fetch settles),
// kept distinct from the result so a failed scan does not re-arm the effect.
const detectionStatus: "idle" | "loading" | "loaded" | "error" =
step !== "detect-site"
? "idle"
: resolvedAttempt !== retryCount
? "loading"
: detectionError
? "error"
: "loaded";

useEffect(() => {
if (step !== "detect-site" || report !== null || loadingDetection) {
if (step !== "detect-site" || resolvedAttempt === retryCount) {
return;
}

setLoadingDetection(true);
let ignore = false;
void fetchSetupDetection().then((next) => {
if (ignore) {
return;
}

if (next === null) {
setDetectionError(true);
setResolvedAttempt(retryCount);
return;
}

setReport(next);
setLoadingDetection(false);
if (next?.primary) {
setDetectionError(false);
setResolvedAttempt(retryCount);
if (next.primary) {
setSelectedAdapter(next.primary.adapter);
setSelectedContentRoot(next.primary.contentRoot);
}
});
}, [step, report, loadingDetection]);

return () => {
ignore = true;
};
}, [step, retryCount, resolvedAttempt]);

const choices = detectionChoices(report);
const ambiguous = report?.primary !== null && report?.safeToApply !== true;
Expand Down Expand Up @@ -209,22 +237,29 @@ export function OnboardingWizard({

{step === "detect-site" && (
<>
{loadingDetection && (
{detectionStatus === "loading" && (
<p className="onboarding-wizard__status" role="status">
Scanning your project…
</p>
)}

{!loadingDetection && report === null && (
{detectionStatus === "error" && (
<div className="notice notice--error" role="alert">
<p className="notice__title">Could not scan your project</p>
<p className="notice__body">
Confirm the publish service is running, then try again.
</p>
<button
type="button"
className="button button--compact"
onClick={() => setRetryCount((count) => count + 1)}
>
Try again
</button>
</div>
)}

{!loadingDetection && report && !report.primary && (
{detectionStatus === "loaded" && report && !report.primary && (
<div className="notice notice--warning" role="status">
<p className="notice__title">We could not detect your site</p>
<p className="notice__body">
Expand All @@ -234,7 +269,7 @@ export function OnboardingWizard({
</div>
)}

{report && activeSuggestion && (
{detectionStatus === "loaded" && report && activeSuggestion && (
<>
<p className="onboarding-wizard__lead">
{friendlyDetectionHeadline(activeSuggestion)}
Expand Down Expand Up @@ -489,7 +524,9 @@ export function OnboardingWizard({
disabled={
applying ||
(step === "detect-site" &&
(loadingDetection || (report !== null && !report.primary)))
(detectionStatus === "loading" ||
detectionStatus === "error" ||
(report !== null && !report.primary)))
Comment on lines +527 to +529

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block continuing after a null post-save refresh

If the initial scan succeeds but the follow-up fetchSetupDetection() in handleApplySettings returns null after saving settings, detectionError is never set and resolvedAttempt still matches retryCount, so the wizard is in a loaded/null state. This disabled check treats report === null as okay, leaving Continue enabled and allowing users to advance into blank confirmation steps with no active suggestion; please either preserve the previous report or treat this null refresh as an error/blocked state.

Useful? React with 👍 / 👎.

}
onClick={() => {
void handleNext();
Expand Down
Loading