Skip to content
Merged
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
9 changes: 7 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,18 @@ jobs:
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('packages/e2e/package.json') }}
# On a key rotation (any packages/e2e dep bump) the fallback restores
# the previous browsers and `playwright install` downloads only the
# missing version instead of a full cold install.
restore-keys: |
playwright-${{ runner.os }}-

- name: Install Playwright browsers
- name: Install Playwright chromium
if: steps.cache-playwright.outputs.cache-hit != 'true'
working-directory: packages/e2e
run: npx playwright install chromium --with-deps

- name: Install Playwright system deps
- name: Install Playwright chromium system deps
if: steps.cache-playwright.outputs.cache-hit == 'true'
working-directory: packages/e2e
run: npx playwright install-deps chromium
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/storage-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,4 @@ jobs:
echo "::group::$f"
bun test "$f"
echo "::endgroup::"
done < <(find apps/api -name '*.integration.test.ts' | sort)
done < <(find apps/api packages -name '*.integration.test.ts' -not -path '*/node_modules/*' | sort)
49 changes: 49 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
outputs:
relevant: ${{ steps.filter.outputs.relevant }}
sandbox: ${{ steps.filter.outputs.sandbox }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Detect relevant changes
id: filter
Expand All @@ -53,10 +54,12 @@ jobs:
'[release]:'*)
echo "relevant=false" >> "$GITHUB_OUTPUT"
echo "sandbox=false" >> "$GITHUB_OUTPUT"
echo "web=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "sandbox=true" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
;;
esac
exit 0
Expand All @@ -68,6 +71,7 @@ jobs:
echo "Could not list PR files — running to be safe."
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "sandbox=true" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changed files:"; echo "$FILES"
Expand All @@ -76,6 +80,7 @@ jobs:
# change (the daemon bundle pulls in typegen, shared, and harness).
relevant=false
sandbox=false
web=false
while IFS= read -r f; do
[ -z "$f" ] && continue
case "$f" in
Expand All @@ -85,9 +90,13 @@ jobs:
case "$f" in
packages/sandbox/*|packages/typegen/*|packages/shared/*|packages/harness/*) sandbox=true ;;
esac
case "$f" in
apps/web/*|packages/ui/*) web=true ;;
esac
done <<< "$FILES"
echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
echo "sandbox=$sandbox" >> "$GITHUB_OUTPUT"
echo "web=$web" >> "$GITHUB_OUTPUT"

format:
needs: changes
Expand Down Expand Up @@ -233,3 +242,43 @@ jobs:
- name: Tear down
if: always()
run: docker rm -f sandbox-smoke || true

# Playwright component tests for the sections-editor field widgets
# (apps/web/ct). Self-contained — real chromium, no server/DB — so it lives
# here rather than e2e.yml, gated to web changes only.
component-tests:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun and install dependencies
uses: ./.github/actions/setup-bun

- name: Cache Playwright browsers
id: cache-playwright
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-ct-${{ runner.os }}-${{ hashFiles('apps/web/package.json') }}
# On a key rotation (playwright version bump) the fallback restores
# the previous browsers and `playwright install` downloads only the
# missing version instead of everything.
restore-keys: |
playwright-ct-${{ runner.os }}-

- name: Install Playwright chromium
if: steps.cache-playwright.outputs.cache-hit != 'true'
working-directory: apps/web
run: npx playwright install chromium --with-deps

- name: Install Playwright chromium system deps
if: steps.cache-playwright.outputs.cache-hit == 'true'
working-directory: apps/web
run: npx playwright install-deps chromium

- name: Run component tests
run: bun run --cwd=apps/web test:ct
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ studio-demo/
# Playwright
apps/web/test-results/
apps/web/playwright-report/
# Playwright component-testing's vite build cache (test:ct)
apps/web/ct/playwright/.cache/
apps/web/screenshots/
packages/e2e/test-results/
packages/e2e/playwright-report/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/**
* user_ask Built-in Tool E2E Tests
* user_ask built-in tool registration/schema tests
*
* End-to-end tests that verify the complete integration of user_ask tool:
* Verifies the user_ask tool wiring in getBuiltInTools():
* - Tool is registered in getBuiltInTools()
* - Tool has correct metadata (description, schemas)
* - Tool has no execute function (client-side only)
* - Input schema validates correctly
* - Output schema is defined correctly
*
* Run with: bun test user-ask.e2e.test.ts
*/

import { describe, expect, test } from "bun:test";
Expand Down
37 changes: 22 additions & 15 deletions apps/web/ct/tests/array-object-drilldown.ct.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { expect, test } from "@playwright/experimental-ct-react";
import type { Locator } from "@playwright/test";
import { SchemaFormHarness } from "../harness/schema-form-harness";
import { sectionWithProps, TEST_RESOLVE_TYPE } from "../harness/fixtures";
import { readBreadcrumb, readFormValue } from "../harness/ct-utils";

/**
* The sortable row wrapper is a role=button whose accessible NAME concatenates
* the label with its inline action buttons ("Alpha Hide item Open actions for
* Alpha"), so an exact-name query no longer matches. Match rows by the visible
* label text instead (the inline action buttons are icon-only, and the array
* header label lives outside any button).
*/
const itemRow = (component: Locator, label: string) =>
component.getByRole("button").filter({ hasText: label });

/**
* ArrayField with OBJECT items + breadcrumb drill-down, exercised through the
* full SchemaFormHarness (raw JSON Schema -> resolveSchema -> SchemaForm).
Expand Down Expand Up @@ -100,12 +111,8 @@ test("item rows are labelled by titleBy with a count badge", async ({

// Empty breadcrumb -> list view with both rows.
await expect.poll(() => readBreadcrumb(component)).toEqual([]);
await expect(
component.getByRole("button", { name: "Alpha", exact: true }),
).toBeVisible();
await expect(
component.getByRole("button", { name: "Beta", exact: true }),
).toBeVisible();
await expect(itemRow(component, "Alpha")).toBeVisible();
await expect(itemRow(component, "Beta")).toBeVisible();

// Count badge reflects the number of items.
await expect(component.getByText("2", { exact: true })).toBeVisible();
Expand All @@ -123,7 +130,7 @@ test("clicking a row drills into that item and shows its Name value", async ({
/>,
);

await component.getByRole("button", { name: "Alpha", exact: true }).click();
await itemRow(component, "Alpha").click();

// Breadcrumb drilled into the Alpha row.
await expect.poll(() => readBreadcrumb(component)).toContain("Alpha");
Expand All @@ -148,7 +155,7 @@ test("editing a drilled-in item updates the correct index", async ({
);

// Drill into the second row (index 1).
await component.getByRole("button", { name: "Beta", exact: true }).click();
await itemRow(component, "Beta").click();
await expect.poll(() => readBreadcrumb(component)).toContain("Beta");

const nameInput = component.getByLabel("Name");
Expand Down Expand Up @@ -187,7 +194,7 @@ test("editing a label field whose value equals the array label keeps the editor
/>,
);

await component.getByRole("button", { name: "Banner", exact: true }).click();
await itemRow(component, "Banner").click();
await expect
.poll(() => readBreadcrumb(component))
.toEqual(["Banner", "Banner"]);
Expand Down Expand Up @@ -225,7 +232,7 @@ test("editing a label to collide with an earlier sibling keeps the opened row",
/>,
);

await component.getByRole("button", { name: "Beta", exact: true }).click();
await itemRow(component, "Beta").click();
const nameInput = component.getByLabel("Name");
await expect(nameInput).toHaveAttribute("id", "cards.1.name");

Expand Down Expand Up @@ -254,9 +261,11 @@ test("deleting a row removes the right item from the array", async ({
/>,
);

// Open the actions menu for the Alpha row (button is in the DOM though hidden).
// Open the actions menu for the Alpha row (button is in the DOM though
// hidden). exact: true — the row wrapper is also a role=button whose
// accessible name CONTAINS "Open actions for Alpha".
await component
.getByRole("button", { name: "Open actions for Alpha" })
.getByRole("button", { name: "Open actions for Alpha", exact: true })
.click();
// DropdownMenu content is portaled onto document.body -> query via page.
await page.getByRole("menuitem", { name: "Delete" }).click();
Expand Down Expand Up @@ -292,7 +301,5 @@ test("fallback label 'Item 1' when itemSchema has no titleBy/title and item has
);

await expect.poll(() => readBreadcrumb(component)).toEqual([]);
await expect(
component.getByRole("button", { name: "Item 1", exact: true }),
).toBeVisible();
await expect(itemRow(component, "Item 1")).toBeVisible();
});
27 changes: 15 additions & 12 deletions apps/web/ct/tests/string-field.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ test("textarea format renders a <textarea> and round-trips typed value", async (
.toEqual({ body: "multi\nline\ntext" });
});

test("rich-text format renders a <textarea> and round-trips typed value", async ({
// rich-text / rich-text-inline now render a TipTap rich editor (toolbar +
// contenteditable ProseMirror) instead of a <textarea>; the model value is HTML.
test("rich-text format renders a rich editor and round-trips typed value as HTML", async ({
mount,
}) => {
const meta = sectionWithProps({
Expand All @@ -84,17 +86,18 @@ test("rich-text format renders a <textarea> and round-trips typed value", async
<SchemaFormHarness meta={meta} resolveType={TEST_RESOLVE_TYPE} />,
);

const field = component.getByLabel("Body");
await expect(field).toBeVisible();
await expect(component.locator("textarea#body")).toBeVisible();
await field.fill("rich content");
await expect(component.getByText("Body")).toBeVisible();
await expect(component.getByRole("button", { name: "Bold" })).toBeVisible();
const editor = component.locator('[contenteditable="true"]');
await expect(editor).toBeVisible();
await editor.fill("rich content");

await expect
.poll(() => readFormValue(component))
.toEqual({ body: "rich content" });
.toEqual({ body: "<p>rich content</p>" });
});

test("rich-text-inline format renders a <textarea> and round-trips typed value", async ({
test("rich-text-inline format renders a rich editor and round-trips typed value as HTML", async ({
mount,
}) => {
const meta = sectionWithProps({
Expand All @@ -104,14 +107,14 @@ test("rich-text-inline format renders a <textarea> and round-trips typed value",
<SchemaFormHarness meta={meta} resolveType={TEST_RESOLVE_TYPE} />,
);

const field = component.getByLabel("Body");
await expect(field).toBeVisible();
await expect(component.locator("textarea#body")).toBeVisible();
await field.fill("inline content");
await expect(component.getByText("Body")).toBeVisible();
const editor = component.locator('[contenteditable="true"]');
await expect(editor).toBeVisible();
await editor.fill("inline content");

await expect
.poll(() => readFormValue(component))
.toEqual({ body: "inline content" });
.toEqual({ body: "<p>inline content</p>" });
});

test("markdown format renders a <textarea> and round-trips typed value", async ({
Expand Down
13 changes: 9 additions & 4 deletions apps/web/ct/tests/widget-dispatch.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ test("string schema → text input", async ({ mount }) => {
await expect(input).toHaveAttribute("type", "text");
});

test("number schema → number input", async ({ mount }) => {
test("number schema → decimal text input", async ({ mount }) => {
const meta = sectionWithProps({
count: { type: "number", title: "Count" },
});
Expand All @@ -32,10 +32,13 @@ test("number schema → number input", async ({ mount }) => {

const input = component.getByLabel("Count");
await expect(input).toBeVisible();
await expect(input).toHaveAttribute("type", "number");
// NumberField keeps the raw typed string in a text input (so "0." survives
// mid-typing) and signals numeric intent via inputmode.
await expect(input).toHaveAttribute("type", "text");
await expect(input).toHaveAttribute("inputmode", "decimal");
});

test("integer schema → number input", async ({ mount }) => {
test("integer schema → numeric text input", async ({ mount }) => {
const meta = sectionWithProps({
quantity: { type: "integer", title: "Quantity" },
});
Expand All @@ -45,7 +48,9 @@ test("integer schema → number input", async ({ mount }) => {

const input = component.getByLabel("Quantity");
await expect(input).toBeVisible();
await expect(input).toHaveAttribute("type", "number");
// Same raw-string NumberField as `number`, but integer → inputmode=numeric.
await expect(input).toHaveAttribute("type", "text");
await expect(input).toHaveAttribute("inputmode", "numeric");
});

test("boolean schema → switch", async ({ mount }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,17 @@ const TOOLS = [
];

let httpServer: ReturnType<typeof Bun.serve>;
let mcpServer: Server;
let baseUrl: string;
let tmp: string;

// A fresh server per test: one stateful transport accepts a single session,
// and each CLI invocation is its own client session.
beforeEach(async () => {
const server = new Server(
const server = (mcpServer = new Server(
{ name: "fake-crm", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS,
}));
Expand Down Expand Up @@ -132,7 +133,15 @@ beforeEach(async () => {
baseUrl = `http://localhost:${httpServer.port}`;
});

afterEach(() => httpServer?.stop(true));
afterEach(async () => {
// Close the MCP server (which closes its transport and any session
// streams) BEFORE stopping the HTTP server: a lingering session stream
// keeps the worker's event loop alive after the suite finishes — under
// `bun test --parallel` that hangs the whole run at exit (the coordinator
// waits on worker exit; this cost a 10-minute timeout on CI once).
await mcpServer?.close().catch(() => {});
httpServer?.stop(true);
});

beforeAll(async () => {
tmp = await mkdtemp(join(tmpdir(), "typegen-e2e-"));
Expand Down
Loading