Two real findings from adversarial review of the previous commit (the v4 UI port's route change), both fixed: - app/tests-layout/*.spec.js (20 files, 133 tests): every one of these navigated to /funpack/ and asserted on composer-specific DOM (.cx-workspace*, window.FunPack) that no longer exists there now that the ported v4 frontend is served at that URL instead. Left as-is they would silently time out on every run. Each file now carries an explicit test.skip(true, "...") with a comment explaining why and pointing at the plan's pixel-exactness step, which is what will eventually rewrite these against the new DOM -- declared and tracked, not quietly broken. - core/tests/test_registry.py's fixture-based tests never exercised the new category field through the real announcement_of()/scan() path -- only test_schema.py's direct validate() calls did, which bypass registry.py's FIELDS name-mapping entirely. A typo there (e.g. "Category" instead of "CATEGORY") would have passed every existing test. The "good module" fixture gained CATEGORY = "guidance" and test_the_good_module_loads now asserts on it, closing the gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
231 lines
12 KiB
JavaScript
231 lines
12 KiB
JavaScript
// Shortcuts (Settings ▸ Shortcuts), autocomplete in the real Constructor
|
|
// prompt box, and the Project tab's Anchor/Postfix/Variables fields --
|
|
// against a real server, where the layout has a real box model. jsdom
|
|
// cannot see either the popover's real position (getBoundingClientRect
|
|
// answers zeros there) or a flex-shrink layout bug that only shows up once
|
|
// a column's content genuinely outgrows its box.
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
// Skipped: / now serves the ported v4 frontend (app/legacy/), not composer --
|
|
// these specs assert on .cx-workspace*/window.FunPack, which no longer exist
|
|
// on the served page. Pending rewrite against the v4-derived DOM as part of
|
|
// the v4-UI-onto-v5-backend port's pixel-exactness verification step (see
|
|
// the plan file). Not deleted: the assertions are still the right SHAPE of
|
|
// check, just against selectors this page no longer has.
|
|
test.skip(true, "composer retired in favor of the v4 UI port -- rewrite against app/legacy's DOM");
|
|
|
|
async function app(page) {
|
|
await page.goto("/funpack/");
|
|
await page.waitForFunction(() => window.FunPack !== undefined);
|
|
}
|
|
|
|
async function openShortcuts(page) {
|
|
await page.getByRole("button", { name: "Settings" }).click();
|
|
await page.locator(".cx-filter-row", { hasText: "Shortcuts" }).click();
|
|
}
|
|
|
|
async function saveShortcut(page, { name, triggers, replacements }) {
|
|
await page.locator(".cx-modal").getByRole("button", { name: "+ Add shortcut" }).click();
|
|
const modal = page.locator(".cx-modal");
|
|
await modal.locator(".cx-field", { hasText: "Name" }).locator("input").fill(name);
|
|
await modal.locator(".cx-field", { hasText: "Triggers" }).locator("textarea").fill(triggers);
|
|
await modal.locator(".cx-field", { hasText: "Replacements" }).locator("textarea").fill(replacements);
|
|
await modal.getByRole("button", { name: "Save" }).click();
|
|
}
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
// The library is a real file on disk (core/shortcuts.py), not per-project
|
|
// state -- left behind, it would leak into whichever test runs next.
|
|
await page.evaluate(() => fetch("/funpack/api/shortcuts/clear", { method: "POST" })).catch(() => {});
|
|
});
|
|
|
|
test("a saved shortcut round-trips through Settings", async ({ page }) => {
|
|
await app(page);
|
|
await openShortcuts(page);
|
|
await saveShortcut(page, { name: "Fox", triggers: "fox", replacements: "red fox" });
|
|
|
|
await expect(page.locator(".cx-modal")).toContainText("fox → red fox");
|
|
await expect(page.locator(".cx-modal")).toContainText("1 shortcut");
|
|
});
|
|
|
|
test("a shortcut with no trigger is refused, and nothing is sent", async ({ page }) => {
|
|
await app(page);
|
|
await openShortcuts(page);
|
|
await page.locator(".cx-modal").getByRole("button", { name: "+ Add shortcut" }).click();
|
|
await page.locator(".cx-modal").getByRole("button", { name: "Save" }).click();
|
|
|
|
await expect(page.locator(".cx-modal")).toContainText("At least one trigger is required.");
|
|
await expect(page.locator(".cx-modal")).toContainText("0 shortcuts");
|
|
});
|
|
|
|
test("typing a saved trigger in the real prompt box suggests it, above the modal", async ({ page }) => {
|
|
await app(page);
|
|
await openShortcuts(page);
|
|
await saveShortcut(page, { name: "Fox", triggers: "fox", replacements: "red fox" });
|
|
// Two "Close"s on this window (the header ✕ and the footer's own button);
|
|
// the footer one is the primary-toned action, the header's is an icon button.
|
|
await page.locator(".cx-modal button.cx-btn-primary", { hasText: "Close" }).click();
|
|
await expect(page.locator(".cx-modal")).toHaveCount(0);
|
|
|
|
await page.locator(".cx-panel-head").getByRole("button", { name: "Constructor" }).click();
|
|
const box = page.locator(".cx-modal textarea").first();
|
|
await box.fill("a fo");
|
|
|
|
const menu = page.locator(".cx-autocomplete");
|
|
await expect(menu).toContainText("fox");
|
|
await expect(menu).toContainText("red fox");
|
|
|
|
await menu.locator(".cx-menu-item").first().click();
|
|
await expect(box).toHaveValue("a fox ");
|
|
});
|
|
|
|
test("the Project tab's Anchor, Postfix and Variables render without overlapping the negative prompt", async ({ page }) => {
|
|
await app(page);
|
|
// A regression test for a real layout bug: `host`'s children (rows,
|
|
// negative) could flex-shrink below their own content once the Project
|
|
// tab grew past a couple of settings rows, spilling their text into each
|
|
// other instead of the panel's own overflow:auto ever getting to scroll.
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").fill("cinematic");
|
|
await page.locator(".cx-field", { hasText: "Postfix" }).locator("textarea").fill("4k");
|
|
await page.getByRole("button", { name: "+ Add variable" }).click();
|
|
|
|
const varsLabel = page.locator(".cx-eyebrow", { hasText: "Variables" });
|
|
const negLabel = page.locator("label.cx-label", { hasText: "Negative prompt" });
|
|
await expect(varsLabel).toBeVisible();
|
|
await expect(negLabel).toBeVisible();
|
|
|
|
const [varsBox, negBox] = await Promise.all([varsLabel.boundingBox(), negLabel.boundingBox()]);
|
|
// Not just "different y" -- the whole vertical span of one must sit above
|
|
// the other's, the same assertion that would have caught the original bug.
|
|
expect(varsBox.y + varsBox.height).toBeLessThanOrEqual(negBox.y + 1);
|
|
});
|
|
|
|
test("anchor, postfix and a variable survive a reload", async ({ page }) => {
|
|
await app(page);
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").fill("cinematic");
|
|
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").blur();
|
|
// A prior test in this file may have left rows of its own (real, persisted
|
|
// project state) -- the row THIS click added is always the last one.
|
|
await page.getByRole("button", { name: "+ Add variable" }).click();
|
|
await page.locator('input[placeholder="name"]').last().fill("subject");
|
|
await page.locator('input[placeholder="name"]').last().blur();
|
|
await page.locator('input[placeholder="value"]').last().fill("a fox");
|
|
await page.locator('input[placeholder="value"]').last().blur();
|
|
|
|
await page.reload();
|
|
await page.waitForFunction(() => window.FunPack !== undefined);
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
await expect(page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea")).toHaveValue("cinematic");
|
|
await expect(page.locator("body")).toContainText("$subject");
|
|
});
|
|
|
|
test("each Generate draws a fresh seed for shortcut expansion, not a fixed hash of the text", async ({ page }) => {
|
|
// core/shortcuts.py's expand() falls back to hashing the literal text when
|
|
// seed is 0/absent -- a FIXED pick, not the "picks at random each time"
|
|
// the Shortcuts editor's own hint promises. Regression test for that: the
|
|
// request boot.js actually sends must carry a real, varying seed.
|
|
await app(page);
|
|
const seeds = [];
|
|
page.on("request", (req) => {
|
|
if (req.url().includes("/api/prompt/expand") && req.method() === "POST") {
|
|
const body = req.postDataJSON();
|
|
if (body && typeof body.seed === "number") seeds.push(body.seed);
|
|
}
|
|
});
|
|
|
|
await page.locator(".cx-panel-head").getByRole("button", { name: "Constructor" }).click();
|
|
await page.locator(".cx-modal textarea").first().fill("a fox runs");
|
|
await page.locator(".cx-modal textarea").first().blur();
|
|
await page.locator(".cx-modal").getByRole("button", { name: "Done" }).click();
|
|
|
|
const generate = page.getByRole("button", { name: "Generate", exact: true });
|
|
for (let i = 0; i < 2; i += 1) {
|
|
await generate.click();
|
|
await expect.poll(() => seeds.length, { timeout: 10000 }).toBeGreaterThan(i);
|
|
// The dev server has no /prompt route, so this run is refused right
|
|
// after queueing -- the button comes back quickly, same as
|
|
// generate_all.spec.js relies on for its own walk.
|
|
await expect(generate).toBeEnabled({ timeout: 10000 });
|
|
}
|
|
|
|
expect(seeds.length).toBeGreaterThanOrEqual(2);
|
|
expect(seeds.every((s) => s > 0)).toBe(true);
|
|
expect(new Set(seeds).size, "the same seed was sent twice in a row").toBeGreaterThan(1);
|
|
});
|
|
|
|
test("removing a variable works even mid-edit on a different row", async ({ page }) => {
|
|
// A real race: row0's blur-commit triggers a full, non-keyed redraw of
|
|
// this whole list (inspector.js's draw() -> node.replaceChildren()) --
|
|
// wired to a plain click, pressing row1's ✕ right after typing in row0
|
|
// (without ever blurring it first) destroys row1's own button mid-
|
|
// gesture, so the click that started on it never arrives and nothing is
|
|
// removed. mousedown+preventDefault on the ✕ (inspector.js's
|
|
// removeButton()) is what this proves still works.
|
|
await app(page);
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
|
|
const add = page.getByRole("button", { name: "+ Add variable" });
|
|
await add.click();
|
|
await page.locator('input[placeholder="name"]').last().fill("row0");
|
|
await add.click();
|
|
await page.locator('input[placeholder="name"]').last().fill("row1");
|
|
|
|
const rowCount = await page.locator('input[placeholder="name"]').count();
|
|
// Type into row0's name field WITHOUT blurring it, then press row1's own
|
|
// ✕ (the last one -- rows are appended, never reordered) in one motion --
|
|
// the exact sequence the race needs.
|
|
const row0Name = page.locator('input[placeholder="name"]').nth(rowCount - 2);
|
|
await row0Name.pressSequentially("-edited", { delay: 20 });
|
|
const removeButtons = page.getByRole("button", { name: "✕" });
|
|
await removeButtons.last().click();
|
|
|
|
await expect(page.locator('input[placeholder="name"]')).toHaveCount(rowCount - 1);
|
|
await expect(page.locator("body")).not.toContainText("$row1");
|
|
});
|
|
|
|
test("a variable's own ✕ removes only that row, even with its own field mid-edit", async ({ page }) => {
|
|
// The row being removed is the one with the uncommitted edit -- removal
|
|
// detaches its still-focused name field, and a browser fires `blur` on a
|
|
// detached-while-focused element regardless of the mousedown's
|
|
// preventDefault. That reentrant commit must find its OWN row already
|
|
// gone (by reference, not by the index it closed over) and no-op, rather
|
|
// than overwriting whatever row now sits at that same numeric slot.
|
|
await app(page);
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
|
|
const add = page.getByRole("button", { name: "+ Add variable" });
|
|
await add.click();
|
|
await page.locator('input[placeholder="name"]').last().fill("rowA");
|
|
await add.click();
|
|
await page.locator('input[placeholder="name"]').last().fill("rowB");
|
|
|
|
const rowCount = await page.locator('input[placeholder="name"]').count();
|
|
const rowANameField = page.locator('input[placeholder="name"]').nth(rowCount - 2);
|
|
await rowANameField.pressSequentially("-edited", { delay: 20 });
|
|
// rowA's OWN ✕, not rowB's -- the second-to-last remove button.
|
|
const removeButtons = page.getByRole("button", { name: "✕" });
|
|
await removeButtons.nth(rowCount - 2).click();
|
|
|
|
await expect(page.locator('input[placeholder="name"]')).toHaveCount(rowCount - 1);
|
|
await expect(page.locator("body")).toContainText("$rowB");
|
|
await expect(page.locator("body")).not.toContainText("rowA-edited");
|
|
});
|
|
|
|
test("a variable's ✕ can be removed with the keyboard", async ({ page }) => {
|
|
await app(page);
|
|
await page.getByRole("tab", { name: "Project" }).click();
|
|
await page.getByRole("button", { name: "+ Add variable" }).click();
|
|
await page.locator('input[placeholder="name"]').last().fill("keyboard");
|
|
await page.locator('input[placeholder="name"]').last().blur();
|
|
|
|
const before = await page.locator('input[placeholder="name"]').count();
|
|
await page.getByRole("button", { name: "✕" }).last().focus();
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page.locator('input[placeholder="name"]')).toHaveCount(before - 1);
|
|
await expect(page.locator("body")).not.toContainText("$keyboard");
|
|
});
|