mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 00:27:43 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
306 lines
7.7 KiB
TypeScript
306 lines
7.7 KiB
TypeScript
import { afterEach, expect, test, vi } from "vitest";
|
|
import { $awareness, startPointerTracking, __testing__ } from "./awareness";
|
|
|
|
const { $pointerPosition, getUserAwareness } = __testing__;
|
|
import { findPageAndSelectorByInstanceId } from "@webstudio-is/project-build/runtime";
|
|
import { selectInstance, selectInstances } from "./nano-states/instances";
|
|
import { $selectedPageId } from "./nano-states";
|
|
import { $user } from "./nano-states/misc";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import { $, renderData } from "@webstudio-is/template";
|
|
|
|
test("find awareness by instance", () => {
|
|
const pages = createDefaultPages({
|
|
homePageId: "homePageId",
|
|
rootInstanceId: "bodyId",
|
|
});
|
|
const { instances } = renderData(
|
|
<$.Body ws:id="bodyId">
|
|
<$.Box ws:id="boxId">
|
|
<$.Text ws:id="textId"></$.Text>
|
|
</$.Box>
|
|
</$.Body>
|
|
);
|
|
expect(findPageAndSelectorByInstanceId(pages, instances, "textId")).toEqual({
|
|
pageId: "homePageId",
|
|
instanceSelector: ["textId", "boxId", "bodyId"],
|
|
});
|
|
});
|
|
|
|
test("find awareness by instance inside of slot", () => {
|
|
const pages = createDefaultPages({
|
|
homePageId: "homePageId",
|
|
rootInstanceId: "bodyId",
|
|
});
|
|
const { instances } = renderData(
|
|
<$.Body ws:id="bodyId">
|
|
<$.Slot ws:id="slotOneId">
|
|
<$.Fragment ws:id="fragmentId">
|
|
<$.Box ws:id="boxId"></$.Box>
|
|
</$.Fragment>
|
|
</$.Slot>
|
|
<$.Slot ws:id="slotTwoId">
|
|
<$.Fragment ws:id="fragmentId">
|
|
<$.Box ws:id="boxId"></$.Box>
|
|
</$.Fragment>
|
|
</$.Slot>
|
|
</$.Body>
|
|
);
|
|
expect(findPageAndSelectorByInstanceId(pages, instances, "boxId")).toEqual({
|
|
pageId: "homePageId",
|
|
instanceSelector: ["boxId", "fragmentId", "slotTwoId", "bodyId"],
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
$selectedPageId.set(undefined);
|
|
selectInstance(undefined);
|
|
$pointerPosition.set(undefined);
|
|
$user.set(undefined);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
test("user awareness uses username and avatar", async () => {
|
|
const user = {
|
|
username: " Ada ",
|
|
email: "ada@example.com",
|
|
image: "https://example.com/avatar.png",
|
|
} as NonNullable<Parameters<typeof $user.set>[0]>;
|
|
|
|
expect(getUserAwareness(user)).toEqual({
|
|
name: "Ada",
|
|
avatarUrl: "https://example.com/avatar.png",
|
|
});
|
|
});
|
|
|
|
test("user awareness falls back to email", async () => {
|
|
const user = {
|
|
username: "",
|
|
email: "ada@example.com",
|
|
image: "",
|
|
} as NonNullable<Parameters<typeof $user.set>[0]>;
|
|
|
|
expect(getUserAwareness(user)).toEqual({
|
|
name: "ada@example.com",
|
|
});
|
|
});
|
|
|
|
test("awareness includes user identity", async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = $awareness.listen(listener);
|
|
const user = {
|
|
username: "Ada",
|
|
email: "ada@example.com",
|
|
image: "https://example.com/avatar.png",
|
|
} as NonNullable<Parameters<typeof $user.set>[0]>;
|
|
|
|
await Promise.resolve();
|
|
listener.mockClear();
|
|
|
|
$user.set(user);
|
|
await Promise.resolve();
|
|
|
|
expect(listener.mock.calls[0]?.[0]).toEqual({
|
|
avatarUrl: "https://example.com/avatar.png",
|
|
name: "Ada",
|
|
pageId: undefined,
|
|
pointerPosition: undefined,
|
|
selectedInstanceIds: undefined,
|
|
});
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("awareness includes selected instance selector", async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = $awareness.listen(listener);
|
|
|
|
await Promise.resolve();
|
|
listener.mockClear();
|
|
|
|
$selectedPageId.set("homePageId");
|
|
selectInstance(["boxId", "bodyId"]);
|
|
await Promise.resolve();
|
|
|
|
expect(listener.mock.calls[0]?.[0]).toMatchObject({
|
|
pageId: "homePageId",
|
|
selectedInstanceIds: ["boxId", "bodyId"],
|
|
});
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("awareness does not include multi-selected instance selectors", async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = $awareness.listen(listener);
|
|
|
|
await Promise.resolve();
|
|
listener.mockClear();
|
|
|
|
$selectedPageId.set("homePageId");
|
|
selectInstances([
|
|
["boxId", "bodyId"],
|
|
["headingId", "bodyId"],
|
|
]);
|
|
await Promise.resolve();
|
|
|
|
expect(listener.mock.calls[0]?.[0]).toMatchObject({
|
|
pageId: "homePageId",
|
|
selectedInstanceIds: undefined,
|
|
});
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("awareness updates are coalesced into one microtask", async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = $awareness.listen(listener);
|
|
|
|
await Promise.resolve();
|
|
listener.mockClear();
|
|
|
|
$pointerPosition.set({ x: 1, y: 2, xRatio: 0.1, yRatio: 0.2 });
|
|
$pointerPosition.set({ x: 3, y: 4, xRatio: 0.3, yRatio: 0.4 });
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
await Promise.resolve();
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener.mock.calls[0]?.[0]).toEqual({
|
|
pageId: undefined,
|
|
pointerPosition: { x: 3, y: 4, xRatio: 0.3, yRatio: 0.4 },
|
|
selectedInstanceIds: undefined,
|
|
});
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test("pointer tracking is throttled to 8fps and keeps latest position", () => {
|
|
vi.useFakeTimers();
|
|
$selectedPageId.set("homePageId");
|
|
|
|
const stop = startPointerTracking();
|
|
|
|
window.dispatchEvent(
|
|
new MouseEvent("pointermove", { clientX: 10, clientY: 20 })
|
|
);
|
|
window.dispatchEvent(
|
|
new MouseEvent("pointermove", { clientX: 11, clientY: 21 })
|
|
);
|
|
window.dispatchEvent(
|
|
new MouseEvent("pointermove", { clientX: 12, clientY: 22 })
|
|
);
|
|
|
|
expect($pointerPosition.get()).toBeUndefined();
|
|
|
|
vi.advanceTimersByTime(125);
|
|
|
|
expect($pointerPosition.get()).toMatchObject({ x: 12, y: 22 });
|
|
|
|
stop();
|
|
});
|
|
|
|
test("pointer tracking maps iframe coordinates to parent viewport", () => {
|
|
vi.useFakeTimers();
|
|
$selectedPageId.set("homePageId");
|
|
|
|
const originalFrameElement = window.frameElement;
|
|
Object.defineProperty(window, "frameElement", {
|
|
configurable: true,
|
|
value: {
|
|
getBoundingClientRect: () => ({
|
|
left: 100,
|
|
top: 200,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const stop = startPointerTracking();
|
|
|
|
window.dispatchEvent(
|
|
new MouseEvent("pointermove", { clientX: 10, clientY: 20 })
|
|
);
|
|
vi.advanceTimersByTime(125);
|
|
|
|
expect($pointerPosition.get()).toMatchObject({ x: 110, y: 220 });
|
|
|
|
stop();
|
|
Object.defineProperty(window, "frameElement", {
|
|
configurable: true,
|
|
value: originalFrameElement,
|
|
});
|
|
});
|
|
|
|
test("pointer tracking maps iframe coordinates with canvas scale", () => {
|
|
vi.useFakeTimers();
|
|
$selectedPageId.set("homePageId");
|
|
|
|
const originalFrameElement = window.frameElement;
|
|
const originalInnerWidth = window.innerWidth;
|
|
const originalInnerHeight = window.innerHeight;
|
|
|
|
Object.defineProperty(window, "innerWidth", {
|
|
configurable: true,
|
|
value: 1000,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
|
|
const originalParent = window.parent;
|
|
Object.defineProperty(window, "parent", {
|
|
configurable: true,
|
|
value: {
|
|
innerWidth: 1200,
|
|
innerHeight: 900,
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(window, "frameElement", {
|
|
configurable: true,
|
|
value: {
|
|
getBoundingClientRect: () => ({
|
|
left: 100,
|
|
top: 200,
|
|
width: 500,
|
|
height: 400,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const stop = startPointerTracking();
|
|
|
|
window.dispatchEvent(
|
|
new MouseEvent("pointermove", { clientX: 400, clientY: 200 })
|
|
);
|
|
vi.advanceTimersByTime(125);
|
|
|
|
// frame is rendered at 0.5 scale => 100 + 400*0.5, 200 + 200*0.5
|
|
expect($pointerPosition.get()).toEqual({
|
|
x: 300,
|
|
y: 300,
|
|
xRatio: 0.4,
|
|
yRatio: 0.25,
|
|
});
|
|
|
|
stop();
|
|
Object.defineProperty(window, "frameElement", {
|
|
configurable: true,
|
|
value: originalFrameElement,
|
|
});
|
|
Object.defineProperty(window, "innerWidth", {
|
|
configurable: true,
|
|
value: originalInnerWidth,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: originalInnerHeight,
|
|
});
|
|
Object.defineProperty(window, "parent", {
|
|
configurable: true,
|
|
value: originalParent,
|
|
});
|
|
});
|