diff --git a/runner/e2e/all-demos.spec.ts b/runner/e2e/all-demos.spec.ts index 06e2c7a8..05aa8fad 100644 --- a/runner/e2e/all-demos.spec.ts +++ b/runner/e2e/all-demos.spec.ts @@ -49,8 +49,12 @@ async function signIn(page: Page) { await page.route("**/broker/userinfo", (route) => route.fulfill({ json: { email: EMAIL } })); } -/** The listing, honouring `?scope=`, plus the per-demo source/metadata/access. */ -async function stubDemos(page: Page) { +/** The listing, honouring `?scope=`, plus the per-demo source/metadata/access. + * + * `extraMine` joins the mine scope only: the owner-filter test counts heads on + * the `all` list ("Everyone (3)"), and a rig-wide extra row would silently move + * those numbers for every test sharing this stub. */ +async function stubDemos(page: Page, extraMine: ReturnType[] = []) { await page.route("**/api/demos?scope=*", (route) => { const scope = new URL(route.request().url()).searchParams.get("scope"); const demos = scope === "all" @@ -59,7 +63,7 @@ async function stubDemos(page: Page) { demo(THEIRS, "Their grid", OTHER), demo("their002", "Their second grid", OTHER), ] - : [demo(MINE, "My grid", EMAIL)]; + : [demo(MINE, "My grid", EMAIL), ...extraMine]; return route.fulfill({ json: { demos, scope } }); }); await page.route("**/api/demos/*/access", (route) => { @@ -125,6 +129,30 @@ test("your own card keeps every action, on either list", async ({ page }) => { await expect(menuItem(page, "Delete")).toBeVisible(); }); +test("a demo the MCP created shows up on your own list", async ({ page }) => { + // The MCP push (DEV-2501) writes through the same worker endpoint the Save + // button does, stamping `created_by` from the caller's token and recording its + // origin in `forked_from` ("mcp:"). To this list that row must be + // indistinguishable from a demo saved in the browser: on *your* list, with the + // full owner's menu — not the read-only card a teammate's demo gets, which is + // what a `created_by` mismatch between the two writers would produce. + await stubShell(page); + await signIn(page); + await stubDemos(page, [ + { ...demo("mcpdemo1", "Pushed from my machine", EMAIL), forked_from: "mcp:react" }, + ]); + await page.goto("/my-demos"); + + await expect(card(page, "Pushed from my machine")).toBeVisible(); + + // First-class owned demo: Open goes to the editor, and Rename and Delete are + // offered — the two actions the read-only menu withholds. + await kebab(page, "Pushed from my machine").click(); + await expect(menuItem(page, "Open")).toHaveAttribute("href", "/edit/mcpdemo1"); + await expect(menuItem(page, "Rename")).toBeVisible(); + await expect(menuItem(page, "Delete")).toBeVisible(); +}); + test("the owner filter shows one person's demos, and the view is a link", async ({ page }) => { await stubShell(page); await signIn(page); diff --git a/runner/e2e/blank-starter.spec.ts b/runner/e2e/blank-starter.spec.ts new file mode 100644 index 00000000..f4f19ecd --- /dev/null +++ b/runner/e2e/blank-starter.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type Page } from "@playwright/test"; + +// The blank starter, and whether anyone can actually reach it (DEV-2499). +// +// Deterministic — no `E2E_LIVE=1`: nothing here renders the example, so the +// Sandpack bundler is aborted and the shell's API calls are stubbed. The +// starter artifacts themselves (`/starter-examples/18/blank*.json`) are the +// app's own static files, served by the same vite preview as the shell — no +// route stub needed, and no network left to be flaky. + +const EMAIL = "dev@handsontable.com"; + +async function stubShell(page: Page) { + await page.route("**/api/versions", (route) => + route.fulfill({ json: { latest: "18.0.0", next: "19.0.0-next.1", versions: ["18.0.0", "17.1.0"] } }), + ); + await page.route("https://sandpack.codesandbox.io/**", (route) => route.abort()); + await page.route("https://sandpack-bundler.codesandbox.io/**", (route) => route.abort()); + await page.route("**/broker/login**", (route) => route.abort()); +} + +async function signIn(page: Page) { + await page.addInitScript(() => sessionStorage.setItem("hot_token", "e2e-token")); + await page.route("**/broker/userinfo", (route) => route.fulfill({ json: { email: EMAIL } })); +} + +const filesPanel = (page: Page) => page.getByRole("region", { name: "Files" }); +const fileRow = (page: Page, path: string) => filesPanel(page).locator(`button[title="${path}"]`); +/** The *visible* editor — scoped to the shown pane, as in docs-examples.spec.ts: + * every open tab keeps its own CodeMirror mounted, so a bare `.cm-content` + * trips strict mode the moment a second file opens. */ +const editor = (page: Page) => page.locator('[data-pane-active="true"] .cm-content'); + +test("the Create tile opens the blank starter, not the showcase", async ({ page }) => { + // "Create" means starting from nothing (DEV-2499): the playground's default is + // the React *showcase* — sample data, ten plugins — which is the wrong answer + // to "give me an empty grid". The tile must point at `?example=blank`, and the + // page it lands on must be the blank starter's file set, not the showcase's. + await stubShell(page); + await signIn(page); + // My demos renders its grid (and the Create tile) for any resolved list, so an + // empty one is the smallest rig that puts the tile on screen. + await page.route("**/api/demos?scope=*", (route) => + route.fulfill({ json: { demos: [], scope: "mine" } }), + ); + await page.goto("/my-demos"); + + const create = page.getByRole("link", { name: "Create" }); + await expect(create).toHaveAttribute("href", "/?example=blank"); + await create.click(); + + // The blank starter's entry file — and none of the react showcase, whose + // entry would be /src/index.tsx. + await expect(fileRow(page, "/index.js")).toBeVisible(); + await expect(fileRow(page, "/src/index.tsx")).toHaveCount(0); +}); + +test("a blank starter opens with nothing but the grid", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=blank"); + + await expect(fileRow(page, "/index.js")).toBeVisible(); + + // The entry file opens on boot. It is ~15 lines, so the whole document is in + // the DOM and `.cm-content` is safe to read — a long file would have to be + // read another way, because CodeMirror virtualises and `.cm-content` would + // only hold the rendered window. + await expect(editor(page)).toContainText("startRows"); + // Nothing but the grid: no showcase module registration, no plugins switched + // on, no sample data. + await expect(editor(page)).not.toContainText("registerAllModules"); + await expect(editor(page)).not.toContainText("contextMenu"); + await expect(editor(page)).not.toContainText("data:"); +}); + +test("the picker lists all three blank templates first", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=blank"); + await expect(fileRow(page, "/index.js")).toBeVisible(); + + // The picker trigger is named for the current example (see + // docs-examples.spec.ts); opening it reveals the current selection's category, + // but the click keeps the test honest if that default ever changes. + await page.getByRole("button", { name: /Blank \(JavaScript\)/ }).first().click(); + await page.getByText("Starter templates", { exact: true }).click(); + + // Blank leads: the starters follow `catalog.examples` order, and the three + // empty grids sit ahead of every showcase — "Create" semantics, not + // alphabetical accident. The first-row assertion is what catches a reorder. + const items = page.getByRole("treeitem"); + await expect(items.first()).toHaveText("Blank (JavaScript)"); + await expect(page.getByRole("treeitem", { name: "Blank (TypeScript)" })).toBeVisible(); + await expect(page.getByRole("treeitem", { name: "Blank (React)" })).toBeVisible(); +}); diff --git a/runner/e2e/description-markdown.spec.ts b/runner/e2e/description-markdown.spec.ts index ca330e6b..186bfcbf 100644 --- a/runner/e2e/description-markdown.spec.ts +++ b/runner/e2e/description-markdown.spec.ts @@ -69,6 +69,22 @@ test("a markdown description renders as formatted text in the sidebar", async ({ await expect(info).not.toContainText("- a [link]"); }); +test("a shared demo's description renders as markdown for its reader", async ({ page }) => { + // Every markdown assertion above runs on /edit/:id — the owner's surface. The + // person the description is *for* reads it on /share/:id, which reaches BoxInfo + // by a different route (ShareRoute, whose user is always null — so no sign-in + // here, deliberately: the reader is anonymous). The stub already covers it: + // share mode hits the same /api/demos/:id + /source pair the editor does. + await stubShell(page); + await stubSavedDemo(page); + await page.goto(`/share/${DEMO_ID}`); + + const info = boxInfo(page); + // Rendered for the reader, not echoed: a real , and no literal syntax. + await expect(info.locator("strong", { hasText: "filters" })).toBeVisible(); + await expect(info).not.toContainText("**filters**"); +}); + test("the toolbar writes markdown into the field without typing it", async ({ page }) => { await stubShell(page); await signIn(page); diff --git a/runner/e2e/files-drop.spec.ts b/runner/e2e/files-drop.spec.ts index 71b2ed53..1af05dec 100644 --- a/runner/e2e/files-drop.spec.ts +++ b/runner/e2e/files-drop.spec.ts @@ -179,6 +179,37 @@ test("the drop target follows the pointer off a row", async ({ page }) => { await expect(fileRow(page, "/src/notes.md")).toHaveCount(0); }); +test("a drop on a folder row lands inside that folder", async ({ page }) => { + // The mirror image of "the drop target follows the pointer off a row" above: + // there the pointer left the /src row and the drop had to fall back to the + // root; here it stays on the folder row and the drop must land inside src/. + // A folder row carries `data-drop-path` but no handler of its own — the + // section's dragover resolves whatever row is under the pointer — so the same + // event sequence drives both, and this one completes the drop instead of + // moving off. + await stubShell(page); + await signIn(page); + await page.goto("/?example=react"); + await expect(accountAvatar(page)).toBeVisible(); + await expect(fileRow(page, "/src/index.tsx")).toBeVisible(); + + const panel = filesPanel(page); + const dataTransfer = await dataTransferOf(page, [{ name: "notes.md", contents: "x" }]); + await panel.dispatchEvent("dragenter", { dataTransfer }); + + // Over the folder row itself (its button is titled "/src"), and the hint + // names the directory — the promise the drop below has to keep. + await fileRow(page, "/src").dispatchEvent("dragover", { dataTransfer }); + await expect(dropHint(page)).toHaveText("Drop into src"); + + await fileRow(page, "/src").dispatchEvent("drop", { dataTransfer }); + + // Inside the folder, and nowhere else — a root /notes.md would mean the drop + // ignored the target the hint had named. + await expect(fileRow(page, "/src/notes.md")).toBeVisible(); + await expect(fileRow(page, "/notes.md")).toHaveCount(0); +}); + test("the drop hint does not move the file rows", async ({ page }) => { // The hint used to sit in the flow at the top of the list, so it pushed every // row down by its own height the moment a drag entered — under a stationary @@ -271,6 +302,35 @@ test("Cancel on a colliding drop adds nothing", async ({ page }) => { await expect(fileRow(page, "/notes.md")).toBeVisible(); }); +test("Replace on a colliding drop overwrites in place, and adds no copy", async ({ page }) => { + await stubShell(page); + await signIn(page); + await page.goto("/?example=react"); + await expect(accountAvatar(page)).toBeVisible(); + await expect(fileRow(page, "/src/index.tsx")).toBeVisible(); + + await dropOnto(page, filesPanel(page), [{ name: "notes.md", contents: "first\n" }]); + await expect(fileRow(page, "/notes.md")).toBeVisible(); + await dropOnto(page, filesPanel(page), [{ name: "notes.md", contents: "second\n" }]); + + await collisionDialog(page).getByRole("button", { name: "Replace" }).click(); + await expect(collisionDialog(page)).toHaveCount(0); + + // In place: the row survives and no `-1` copy appeared alongside it. + await expect(fileRow(page, "/notes.md")).toBeVisible(); + await expect(fileRow(page, "/notes-1.md")).toHaveCount(0); + + // The crux is the content: Replace and Cancel both leave exactly one + // /notes.md row, so row assertions alone cannot tell them apart. `commit` + // re-selects the dropped file, which makes its pane the active one, and the + // file is one line — the whole document is in the DOM, so `.cm-content` is + // safe to read (CodeMirror virtualises long files; this one isn't). + await expect(rowOf(page, "/notes.md")).toHaveAttribute("data-active", "true"); + const editor = page.locator('[data-pane-active="true"] .cm-content'); + await expect(editor).toContainText("second"); + await expect(editor).not.toContainText("first"); +}); + test("anonymous play has no drop target", async ({ page }) => { await stubShell(page); await page.goto("/?example=react");