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
34 changes: 31 additions & 3 deletions runner/e2e/all-demos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof demo>[] = []) {
await page.route("**/api/demos?scope=*", (route) => {
const scope = new URL(route.request().url()).searchParams.get("scope");
const demos = scope === "all"
Expand 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) => {
Expand Down Expand Up @@ -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:<framework>"). 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);
Expand Down
94 changes: 94 additions & 0 deletions runner/e2e/blank-starter.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
16 changes: 16 additions & 0 deletions runner/e2e/description-markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>, 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);
Expand Down
60 changes: 60 additions & 0 deletions runner/e2e/files-drop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
Loading