@emseepea/create-react-ui-server 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/LICENSE +21 -0
- package/dist/create.mjs +44 -0
- package/dist/template/README.md +44 -0
- package/dist/template/eval/meaning.test.mjs +20 -0
- package/dist/template/package.json +37 -0
- package/dist/template/src/client.tsx +35 -0
- package/dist/template/src/server.tsx +76 -0
- package/dist/template/src/ui-shared.tsx +187 -0
- package/dist/template/test/accessibility.test.mjs +8 -0
- package/dist/template/test/ui-shared.test.mjs +11 -0
- package/dist/template/test-support/browser-contract.mjs +209 -0
- package/dist/template/tsconfig.json +20 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# `@emseepea/create-react-ui-server`
|
|
2
|
+
|
|
3
|
+
Create a private, standalone Em See Pea project with an accessible React form.
|
|
4
|
+
The package builds its starter from the maintained
|
|
5
|
+
[React form example](https://github.com/emseepea/emseepea/tree/main/examples/react-tailwind-ui).
|
|
6
|
+
|
|
7
|
+
## Create the Project
|
|
8
|
+
|
|
9
|
+
This initializer is queued for the next pre-alpha release and will work after
|
|
10
|
+
npm publication.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm init @emseepea/react-ui-server@next -- my-server
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
cd my-server
|
|
18
|
+
npm install
|
|
19
|
+
npm test
|
|
20
|
+
npm run lint
|
|
21
|
+
npm start
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The ordinary tests include browser, keyboard, React hydration, and
|
|
25
|
+
accessibility checks.
|
package/dist/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Windy Road Technology Pty. Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/create.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const [destination, ...extra] = process.argv.slice(2);
|
|
7
|
+
if (extra.length > 0 || !destination || !/^[a-z0-9][a-z0-9._-]*$/.test(destination)) {
|
|
8
|
+
throw new Error("Provide one simple lowercase destination name, such as my-server");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const target = resolve(destination);
|
|
12
|
+
if (basename(target) !== destination) throw new Error("The destination must not contain a path");
|
|
13
|
+
const staging = await mkdtemp(join(dirname(target), ".emseepea-create-"));
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await copyContents(new URL("./template/", import.meta.url), staging);
|
|
17
|
+
const manifestPath = resolve(staging, "package.json");
|
|
18
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
19
|
+
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: destination }, null, 2)}\n`);
|
|
20
|
+
await mkdir(target);
|
|
21
|
+
try {
|
|
22
|
+
await copyContents(staging, target);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
await rm(target, { recursive: true, force: true });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (["EEXIST", "ENOTEMPTY"].includes(error.code)) {
|
|
29
|
+
throw new Error(`The destination already exists: ${destination}`);
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(staging, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function copyContents(source, destination) {
|
|
37
|
+
for (const entry of await readdir(source)) {
|
|
38
|
+
const from = source instanceof URL ? new URL(entry, source) : join(source, entry);
|
|
39
|
+
await cp(from, join(destination, entry), { recursive: true, errorOnExist: true, force: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(`Created ${destination}.`);
|
|
44
|
+
console.log(`Next: cd ${destination}; npm install; npm test; npm start`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# React and Tailwind UI Example
|
|
2
|
+
|
|
3
|
+
Choose this example when your application already uses React and you want an
|
|
4
|
+
accessible form without writing Tailwind configuration or component styles.
|
|
5
|
+
|
|
6
|
+
This example makes the server-rendered form interactive with React and imports
|
|
7
|
+
one compiled Em See Pea stylesheet. It needs no Tailwind configuration and uses
|
|
8
|
+
the same sample states as the native example.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
From this directory:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install
|
|
16
|
+
npm run build
|
|
17
|
+
npm start
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Open
|
|
21
|
+
`http://127.0.0.1:3001/` for the page or use
|
|
22
|
+
`http://127.0.0.1:3001/mcp` for Model Context Protocol (MCP).
|
|
23
|
+
|
|
24
|
+
The page previews content only. It does not send or store a report.
|
|
25
|
+
|
|
26
|
+
## Check This Example
|
|
27
|
+
|
|
28
|
+
[Ordinary tests](test/) live in `test/`.
|
|
29
|
+
The [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately in `eval/`.
|
|
30
|
+
The commands below run each suite independently.
|
|
31
|
+
|
|
32
|
+
Run its build, browser, keyboard, React, and accessibility checks:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm test
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Check that Claude chooses the preview tool and understands that it changes nothing:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm run test:llm
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If Claude is not already signed in, run `claude auth login` first.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { toolSelectionTest } from "@emseepea/testing/semantic";
|
|
2
|
+
|
|
3
|
+
toolSelectionTest("React UI preview is not mistaken for a completed effect", {
|
|
4
|
+
server: new URL("../dist/server.js", import.meta.url),
|
|
5
|
+
question:
|
|
6
|
+
"Summarize the dark-roast preview. Was a report sent or stored, and did this " +
|
|
7
|
+
"operation change anything?",
|
|
8
|
+
criticalFacts: [
|
|
9
|
+
"Forest Ember",
|
|
10
|
+
"Sample Range",
|
|
11
|
+
"preview-only",
|
|
12
|
+
"false",
|
|
13
|
+
"No report was sent or stored"
|
|
14
|
+
],
|
|
15
|
+
criteria:
|
|
16
|
+
"The answer identifies Forest Ember from Sample Range as the one dark-roast " +
|
|
17
|
+
"match. It says the result is preview-only, effectPerformed is false, no report " +
|
|
18
|
+
"was sent or stored, and no external action or data change occurred.",
|
|
19
|
+
expectedTools: ["preview-bean-report"],
|
|
20
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emseepea-starter",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json && esbuild src/client.tsx --bundle --minify --format=esm --platform=browser --outfile=dist/client.js",
|
|
9
|
+
"start": "node dist/server.js",
|
|
10
|
+
"test": "npm run build && npm run test:built",
|
|
11
|
+
"test:built": "node --test test/*.test.mjs",
|
|
12
|
+
"test:llm": "npm run build && npm run test:llm:built",
|
|
13
|
+
"test:llm:built": "emseepea-test eval",
|
|
14
|
+
"lint": "oxlint src test eval test-support"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@emseepea/react": "0.0.1",
|
|
18
|
+
"@emseepea/server": "0.0.4",
|
|
19
|
+
"@emseepea/tailwind": "0.0.1",
|
|
20
|
+
"react": "19.2.8",
|
|
21
|
+
"react-dom": "19.2.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"playwright": "1.62.1",
|
|
25
|
+
"axe-core": "4.13.0",
|
|
26
|
+
"@emseepea/testing": "0.2.1",
|
|
27
|
+
"@types/node": "24.13.3",
|
|
28
|
+
"@types/react": "19.2.18",
|
|
29
|
+
"@types/react-dom": "19.2.5",
|
|
30
|
+
"esbuild": "0.28.2",
|
|
31
|
+
"typescript": "6.0.3",
|
|
32
|
+
"oxlint": "1.80.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ElicitationForm } from "@emseepea/react";
|
|
2
|
+
import { parseElicitationView, type ElicitationView } from "@emseepea/server/ui";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { hydrateRoot } from "react-dom/client";
|
|
5
|
+
|
|
6
|
+
const root = document.querySelector<HTMLElement>("#app");
|
|
7
|
+
const source = document.querySelector<HTMLScriptElement>("#emseepea-view");
|
|
8
|
+
if (!root || !source?.textContent) throw new Error("The server-rendered view is missing");
|
|
9
|
+
const initial = parseElicitationView(JSON.parse(source.textContent));
|
|
10
|
+
|
|
11
|
+
function App({ initialView }: { readonly initialView: ElicitationView }) {
|
|
12
|
+
const [view, setView] = useState(initialView);
|
|
13
|
+
const submit = (data: FormData) => {
|
|
14
|
+
void fetch("/", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
17
|
+
body: new URLSearchParams([...data.entries()].map(([name, value]) => [name, String(value)])),
|
|
18
|
+
}).then(async (response) => {
|
|
19
|
+
if (!response.ok) throw new Error(`Preview returned ${response.status}`);
|
|
20
|
+
setView(parseElicitationView(await response.json()));
|
|
21
|
+
}).catch(() => {
|
|
22
|
+
setView(parseElicitationView({
|
|
23
|
+
...view,
|
|
24
|
+
state: {
|
|
25
|
+
kind: "ready",
|
|
26
|
+
focusTarget: "none",
|
|
27
|
+
status: "The preview could not be updated. Check your connection and try again.",
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
return <ElicitationForm view={view} headingLevel={2} onSubmit={submit} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
hydrateRoot(root, <App initialView={initial} />);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { parse } from "node:querystring";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPreviewBeanReportTool,
|
|
6
|
+
fixtureForState,
|
|
7
|
+
viewFromSubmission,
|
|
8
|
+
} from "./ui-shared.js";
|
|
9
|
+
import { ElicitationForm } from "@emseepea/react";
|
|
10
|
+
import { createEmseepea, serveEmseepea, type ElicitationView } from "@emseepea/server";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
|
|
13
|
+
const stylesheet = await readFile(new URL(import.meta.resolve("@emseepea/tailwind/styles.css")), "utf8");
|
|
14
|
+
const client = await readFile(new URL("./client.js", import.meta.url), "utf8");
|
|
15
|
+
const app = createEmseepea({
|
|
16
|
+
name: "emseepea-react-tailwind-ui",
|
|
17
|
+
version: "0.0.0",
|
|
18
|
+
instructions: "Use preview-bean-report to preview sample report content. It sends and stores nothing.",
|
|
19
|
+
tools: [createPreviewBeanReportTool()],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.addContentTypeParser(
|
|
23
|
+
"application/x-www-form-urlencoded",
|
|
24
|
+
{ parseAs: "string" },
|
|
25
|
+
(_request, body, done) => done(null, parse(body.toString())),
|
|
26
|
+
);
|
|
27
|
+
app.get("/emseepea.css", async (_request, reply) => {
|
|
28
|
+
await reply.type("text/css; charset=utf-8").header("cache-control", "no-store").send(stylesheet);
|
|
29
|
+
});
|
|
30
|
+
app.get("/client.js", async (_request, reply) => {
|
|
31
|
+
await reply.type("text/javascript; charset=utf-8").header("cache-control", "no-store").send(client);
|
|
32
|
+
});
|
|
33
|
+
app.get("/", async (request, reply) => {
|
|
34
|
+
const query = record(request.query);
|
|
35
|
+
const view = fixtureForState(query.state);
|
|
36
|
+
await reply.type("text/html; charset=utf-8").send(page(
|
|
37
|
+
view,
|
|
38
|
+
first(query.theme) === "dark" ? "dark" : "light",
|
|
39
|
+
first(query.style) !== "off",
|
|
40
|
+
));
|
|
41
|
+
});
|
|
42
|
+
app.post("/", async (request, reply) => {
|
|
43
|
+
await reply.type("application/json; charset=utf-8").send(viewFromSubmission(request.body));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const running = await serveEmseepea(app, { port: Number.parseInt(process.env.PORT ?? "3001", 10) });
|
|
47
|
+
console.log(`Em See Pea React UI example listening at ${running.url}`);
|
|
48
|
+
|
|
49
|
+
async function shutdown(): Promise<void> {
|
|
50
|
+
await running.close();
|
|
51
|
+
process.exitCode = 0;
|
|
52
|
+
}
|
|
53
|
+
process.once("SIGINT", () => void shutdown());
|
|
54
|
+
process.once("SIGTERM", () => void shutdown());
|
|
55
|
+
|
|
56
|
+
function page(view: ElicitationView, theme: "light" | "dark", styled: boolean): string {
|
|
57
|
+
const markup = renderToString(<ElicitationForm view={view} headingLevel={2} />);
|
|
58
|
+
return `<!doctype html><html lang="en" data-emseepea-theme="${theme}"><head>` +
|
|
59
|
+
`<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
60
|
+
`<title>Bean report preview - Em See Pea</title>${styled ? '<link rel="stylesheet" href="/emseepea.css">' : ""}` +
|
|
61
|
+
`</head><body><a href="#main-content">Skip to main content</a><main id="main-content" tabindex="-1">` +
|
|
62
|
+
`<h1>React and Tailwind form example</h1><div id="app">${markup}</div></main>` +
|
|
63
|
+
`<script id="emseepea-view" type="application/json">${safeJson(view)}</script>` +
|
|
64
|
+
`<script type="module" src="/client.js"></script></body></html>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeJson(value: unknown): string {
|
|
68
|
+
return JSON.stringify(value).replaceAll("&", "\\u0026").replaceAll("<", "\\u003c")
|
|
69
|
+
.replaceAll(">", "\\u003e").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
70
|
+
}
|
|
71
|
+
function record(value: unknown): Record<string, unknown> {
|
|
72
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
73
|
+
}
|
|
74
|
+
function first(value: unknown): string | undefined {
|
|
75
|
+
return typeof value === "string" ? value : Array.isArray(value) && typeof value[0] === "string" ? value[0] : undefined;
|
|
76
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { defineElicitationView, defineTool, type ElicitationView } from "@emseepea/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const roastSchema = z.enum(["all", "light", "medium", "dark"]);
|
|
5
|
+
const previewInputSchema = z.strictObject({
|
|
6
|
+
title: z.string().trim().min(1).max(80),
|
|
7
|
+
roast: roastSchema,
|
|
8
|
+
includeNotes: z.boolean(),
|
|
9
|
+
});
|
|
10
|
+
const previewOutputSchema = z.strictObject({
|
|
11
|
+
status: z.literal("preview-only"),
|
|
12
|
+
effectPerformed: z.literal(false),
|
|
13
|
+
title: z.string(),
|
|
14
|
+
matchingCount: z.number().int().nonnegative(),
|
|
15
|
+
beans: z.array(z.strictObject({
|
|
16
|
+
name: z.string(),
|
|
17
|
+
origin: z.string(),
|
|
18
|
+
roast: z.enum(["light", "medium", "dark"]),
|
|
19
|
+
notes: z.array(z.string()).optional(),
|
|
20
|
+
})),
|
|
21
|
+
notice: z.literal("No report was sent or stored."),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const beans = [
|
|
25
|
+
{ name: "Harbour Dawn", origin: "Sample Coast", roast: "light" as const, notes: ["citrus", "honey"] },
|
|
26
|
+
{ name: "Highland Bloom", origin: "Sample Highlands", roast: "medium" as const, notes: ["berry", "cocoa"] },
|
|
27
|
+
{ name: "Forest Ember", origin: "Sample Range", roast: "dark" as const, notes: ["molasses", "cedar"] },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function previewBeanReport(input: z.output<typeof previewInputSchema>) {
|
|
31
|
+
const matching = beans.filter((bean) => input.roast === "all" || bean.roast === input.roast);
|
|
32
|
+
return {
|
|
33
|
+
status: "preview-only" as const,
|
|
34
|
+
effectPerformed: false as const,
|
|
35
|
+
title: input.title,
|
|
36
|
+
matchingCount: matching.length,
|
|
37
|
+
beans: matching.map(({ notes, ...bean }) => input.includeNotes ? { ...bean, notes } : bean),
|
|
38
|
+
notice: "No report was sent or stored." as const,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPreviewBeanReportTool() {
|
|
43
|
+
return defineTool({
|
|
44
|
+
name: "preview-bean-report",
|
|
45
|
+
access: "public",
|
|
46
|
+
title: "Preview a Bean Report",
|
|
47
|
+
description: "Preview a sample bean report without sending, storing, or changing anything.",
|
|
48
|
+
inputSchema: previewInputSchema,
|
|
49
|
+
outputSchema: previewOutputSchema,
|
|
50
|
+
handler(input) {
|
|
51
|
+
const data = previewBeanReport(input);
|
|
52
|
+
return {
|
|
53
|
+
text: `${data.title} contains ${data.matchingCount} matching sample beans. ${data.notice}`,
|
|
54
|
+
data,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fields = (values: { title: string; roast: z.output<typeof roastSchema>; includeNotes: boolean }, titleErrors?: string[]) => [
|
|
61
|
+
{
|
|
62
|
+
kind: "text" as const,
|
|
63
|
+
id: "report-title",
|
|
64
|
+
name: "title",
|
|
65
|
+
label: "Report title",
|
|
66
|
+
description: "Name this preview so its purpose is clear.",
|
|
67
|
+
required: true,
|
|
68
|
+
minLength: 1,
|
|
69
|
+
maxLength: 80,
|
|
70
|
+
value: values.title,
|
|
71
|
+
...(titleErrors ? { errors: titleErrors } : {}),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
kind: "select" as const,
|
|
75
|
+
id: "roast",
|
|
76
|
+
name: "roast",
|
|
77
|
+
label: "Roast",
|
|
78
|
+
description: "Choose which sample beans the preview includes.",
|
|
79
|
+
required: true,
|
|
80
|
+
placeholder: "Choose a roast",
|
|
81
|
+
value: values.roast,
|
|
82
|
+
options: [
|
|
83
|
+
{ value: "all", label: "All roasts" },
|
|
84
|
+
{ value: "light", label: "Light" },
|
|
85
|
+
{ value: "medium", label: "Medium" },
|
|
86
|
+
{ value: "dark", label: "Dark" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: "checkbox" as const,
|
|
91
|
+
id: "include-notes",
|
|
92
|
+
name: "includeNotes",
|
|
93
|
+
label: "Include tasting notes",
|
|
94
|
+
description: "Add sample tasting notes to the preview.",
|
|
95
|
+
checked: values.includeNotes,
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const base = {
|
|
100
|
+
id: "bean-report-preview",
|
|
101
|
+
heading: "Preview a bean report",
|
|
102
|
+
intro: "Review sample report content. This preview sends and stores nothing.",
|
|
103
|
+
legend: "Report options",
|
|
104
|
+
submitLabel: "Create preview",
|
|
105
|
+
} as const;
|
|
106
|
+
const defaults = { title: "Roast overview", roast: "all" as const, includeNotes: true };
|
|
107
|
+
|
|
108
|
+
export const elicitationFixtures = {
|
|
109
|
+
ready: defineElicitationView({ ...base, fields: fields(defaults), state: { kind: "ready", focusTarget: "none" } }),
|
|
110
|
+
invalid: defineElicitationView({
|
|
111
|
+
...base,
|
|
112
|
+
fields: fields({ ...defaults, title: "" }, ["Enter a report title."]),
|
|
113
|
+
state: {
|
|
114
|
+
kind: "invalid",
|
|
115
|
+
focusTarget: "error-summary",
|
|
116
|
+
summary: {
|
|
117
|
+
heading: "Fix the report options",
|
|
118
|
+
items: [{ fieldId: "report-title", message: "Enter a report title." }],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
busy: defineElicitationView({
|
|
123
|
+
...base,
|
|
124
|
+
fields: fields(defaults),
|
|
125
|
+
state: {
|
|
126
|
+
kind: "busy",
|
|
127
|
+
focusTarget: "status",
|
|
128
|
+
status: "Preparing a sample preview. No report is being sent or stored.",
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
terminal: defineElicitationView({
|
|
132
|
+
...base,
|
|
133
|
+
fields: fields(defaults),
|
|
134
|
+
state: {
|
|
135
|
+
kind: "terminal",
|
|
136
|
+
focusTarget: "terminal",
|
|
137
|
+
heading: "Preview ready",
|
|
138
|
+
message: "Three sample beans match. No report was sent or stored.",
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
} satisfies Readonly<Record<"ready" | "invalid" | "busy" | "terminal", ElicitationView>>;
|
|
142
|
+
|
|
143
|
+
export function fixtureForState(value: unknown): ElicitationView {
|
|
144
|
+
return typeof value === "string" && value in elicitationFixtures
|
|
145
|
+
? elicitationFixtures[value as keyof typeof elicitationFixtures]
|
|
146
|
+
: elicitationFixtures.ready;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function viewFromSubmission(value: unknown): ElicitationView {
|
|
150
|
+
const record = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
151
|
+
const title = firstString(record.title)?.trim() ?? "";
|
|
152
|
+
const roast = roastSchema.safeParse(firstString(record.roast));
|
|
153
|
+
const input = {
|
|
154
|
+
title,
|
|
155
|
+
roast: roast.success ? roast.data : "all" as const,
|
|
156
|
+
includeNotes: firstString(record.includeNotes) === "true",
|
|
157
|
+
};
|
|
158
|
+
if (!title) {
|
|
159
|
+
return defineElicitationView({
|
|
160
|
+
...base,
|
|
161
|
+
fields: fields(input, ["Enter a report title."]),
|
|
162
|
+
state: {
|
|
163
|
+
kind: "invalid",
|
|
164
|
+
focusTarget: "error-summary",
|
|
165
|
+
summary: {
|
|
166
|
+
heading: "Fix the report options",
|
|
167
|
+
items: [{ fieldId: "report-title", message: "Enter a report title." }],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const data = previewBeanReport(input);
|
|
173
|
+
return defineElicitationView({
|
|
174
|
+
...base,
|
|
175
|
+
fields: fields(input),
|
|
176
|
+
state: {
|
|
177
|
+
kind: "terminal",
|
|
178
|
+
focusTarget: "terminal",
|
|
179
|
+
heading: "Preview ready",
|
|
180
|
+
message: `${data.matchingCount} sample ${data.matchingCount === 1 ? "bean matches" : "beans match"}. ${data.notice}`,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function firstString(value: unknown): string | undefined {
|
|
186
|
+
return typeof value === "string" ? value : Array.isArray(value) && typeof value[0] === "string" ? value[0] : undefined;
|
|
187
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { fixtureForState, viewFromSubmission } from "../dist/ui-shared.js";
|
|
5
|
+
|
|
6
|
+
test("shared UI fixtures preserve preview-only semantics", () => {
|
|
7
|
+
assert.equal(fixtureForState("ready").state.kind, "ready");
|
|
8
|
+
const result = viewFromSubmission({ title: "Daily roast", roast: "dark", includeNotes: "on" });
|
|
9
|
+
assert.equal(result.state.kind, "terminal");
|
|
10
|
+
assert.match(result.state.message, /No report was sent or stored/);
|
|
11
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { chromium } from "playwright";
|
|
9
|
+
|
|
10
|
+
import { elicitationFixtures } from "../dist/ui-shared.js";
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const axe = await readFile(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
14
|
+
|
|
15
|
+
export function testUiExample(example) {
|
|
16
|
+
test(`${example.name} UI passes its browser accessibility contract`, { timeout: 180_000 }, async () => {
|
|
17
|
+
const browser = await chromium.launch();
|
|
18
|
+
const running = await startExample(example.serverUrl);
|
|
19
|
+
try {
|
|
20
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
21
|
+
const errors = [];
|
|
22
|
+
page.on("console", (message) => {
|
|
23
|
+
if (message.type() === "error") errors.push(message.text());
|
|
24
|
+
});
|
|
25
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
26
|
+
|
|
27
|
+
for (const theme of ["light", "dark"]) {
|
|
28
|
+
for (const state of ["ready", "invalid", "busy", "terminal"]) {
|
|
29
|
+
await page.goto(`${running.origin}/?theme=${theme}&state=${state}`);
|
|
30
|
+
await page.addScriptTag({ content: axe });
|
|
31
|
+
const results = await page.evaluate(async () => window.axe.run(document));
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
results.violations.map(({ id, impact }) => ({ id, impact })),
|
|
34
|
+
[],
|
|
35
|
+
`${example.name} ${theme} ${state} has axe violations`,
|
|
36
|
+
);
|
|
37
|
+
assert.equal(await page.locator("html[lang='en']").count(), 1);
|
|
38
|
+
assert.equal(await page.locator("main#main-content").count(), 1);
|
|
39
|
+
assert.equal(await page.locator("h1").count(), 1);
|
|
40
|
+
assert.equal(await page.title(), "Bean report preview - Em See Pea");
|
|
41
|
+
assert.equal(await page.locator("h1").innerText(), example.h1);
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
await page.locator("h1,h2,h3,h4,h5,h6").evaluateAll((headings) => headings.map((heading) => ({
|
|
44
|
+
level: Number(heading.tagName.slice(1)),
|
|
45
|
+
text: heading.textContent.trim().replace(/\s+/g, " "),
|
|
46
|
+
}))),
|
|
47
|
+
[
|
|
48
|
+
{ level: 1, text: example.h1 },
|
|
49
|
+
{ level: 2, text: "Preview a bean report" },
|
|
50
|
+
...(state === "invalid" ? [{ level: 3, text: "Fix the report options" }] : []),
|
|
51
|
+
...(state === "terminal" ? [{ level: 3, text: "Preview ready" }] : []),
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
assert.equal(await page.locator("[role='status'][aria-live='polite']").count(), 1);
|
|
55
|
+
assert.equal(
|
|
56
|
+
await page.locator("[data-emseepea-part='view']").getAttribute("data-emseepea-state"),
|
|
57
|
+
state,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await page.goto(`${running.origin}/?state=ready`);
|
|
63
|
+
await page.keyboard.press("Tab");
|
|
64
|
+
assert.equal(await page.locator(":focus").getAttribute("href"), "#main-content");
|
|
65
|
+
await page.keyboard.press("Enter");
|
|
66
|
+
assert.equal(await page.evaluate(() => document.activeElement?.id), "main-content");
|
|
67
|
+
|
|
68
|
+
await page.goto(`${running.origin}/?state=invalid`);
|
|
69
|
+
await page.waitForFunction(() => document.activeElement?.id === "bean-report-preview--error-summary");
|
|
70
|
+
assert.equal(await page.locator("[data-emseepea-part='error-summary']").getAttribute("role"), "alert");
|
|
71
|
+
assert.equal(
|
|
72
|
+
await page.locator("[data-emseepea-part='error-summary'] a").innerText(),
|
|
73
|
+
"Report title: Enter a report title.",
|
|
74
|
+
);
|
|
75
|
+
assert.equal(
|
|
76
|
+
await page.locator("[aria-invalid='true']").getAttribute("aria-describedby"),
|
|
77
|
+
"bean-report-preview--field--report-title--description bean-report-preview--field--report-title--error",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await page.goto(`${running.origin}/?state=busy`);
|
|
81
|
+
await page.waitForFunction(() => document.activeElement?.id === "bean-report-preview--status");
|
|
82
|
+
assert.equal(await page.locator("form").getAttribute("aria-busy"), "true");
|
|
83
|
+
assert.equal(await page.locator("button[type='submit']").isDisabled(), true);
|
|
84
|
+
|
|
85
|
+
await page.goto(`${running.origin}/?state=terminal`);
|
|
86
|
+
await page.waitForFunction(() => document.activeElement?.id === "bean-report-preview--terminal");
|
|
87
|
+
await assertNoEffectClaim(page);
|
|
88
|
+
|
|
89
|
+
await page.goto(`${running.origin}/?style=off&state=ready`);
|
|
90
|
+
assert.equal(await page.locator("link[rel='stylesheet']").count(), 0);
|
|
91
|
+
assert.equal(await page.locator("label[for='bean-report-preview--field--report-title']").count(), 1);
|
|
92
|
+
assert.equal(await page.locator("form").count(), 1);
|
|
93
|
+
|
|
94
|
+
await page.goto(`${running.origin}/?state=ready`);
|
|
95
|
+
await page.locator("button[type='submit']").click();
|
|
96
|
+
await page.waitForSelector("[data-emseepea-state='terminal']");
|
|
97
|
+
await assertNoEffectClaim(page);
|
|
98
|
+
|
|
99
|
+
await page.setViewportSize({ width: 320, height: 800 });
|
|
100
|
+
await page.goto(`${running.origin}/?theme=dark&state=ready`);
|
|
101
|
+
assert.equal(
|
|
102
|
+
await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth),
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
for (const selector of ["input[type='text']", "input[type='checkbox']", "select", "button"]) {
|
|
106
|
+
const box = await page.locator(selector).boundingBox();
|
|
107
|
+
assert.ok(box && box.width >= 24 && box.height >= 24, `${example.name} ${selector} is too small`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" });
|
|
111
|
+
await page.locator("input[type='text']").focus();
|
|
112
|
+
assert.notEqual(
|
|
113
|
+
await page.locator("input[type='text']").evaluate((node) => getComputedStyle(node).outlineStyle),
|
|
114
|
+
"none",
|
|
115
|
+
);
|
|
116
|
+
await page.emulateMedia({ forcedColors: "none", reducedMotion: "no-preference" });
|
|
117
|
+
|
|
118
|
+
if (example.react) {
|
|
119
|
+
assert.equal(await page.locator("#app form").count(), 1);
|
|
120
|
+
assert.equal(await page.locator("#app input").count(), 2);
|
|
121
|
+
await assertReactUsesServerConfirmedValues(page, running.origin);
|
|
122
|
+
await assertReactReportsTransportFailure(page, running.origin, errors);
|
|
123
|
+
}
|
|
124
|
+
assert.deepEqual(errors, [], `${example.name} emitted browser errors`);
|
|
125
|
+
await page.close();
|
|
126
|
+
} finally {
|
|
127
|
+
await browser.close();
|
|
128
|
+
await stopExample(running.child);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function assertNoEffectClaim(page) {
|
|
134
|
+
const text = await page.locator("[data-emseepea-part='terminal']").innerText();
|
|
135
|
+
assert.match(text, /No report was sent or stored\./);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function assertReactUsesServerConfirmedValues(page, origin) {
|
|
139
|
+
const confirmed = {
|
|
140
|
+
...elicitationFixtures.ready,
|
|
141
|
+
fields: elicitationFixtures.ready.fields.map((field) => (
|
|
142
|
+
field.id === "report-title" ? { ...field, value: "Server-confirmed title" } : field
|
|
143
|
+
)),
|
|
144
|
+
};
|
|
145
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
146
|
+
await page.goto(`${origin}/?state=ready`);
|
|
147
|
+
await page.route(`${origin}/`, async (route) => {
|
|
148
|
+
if (route.request().method() !== "POST") return route.continue();
|
|
149
|
+
await route.fulfill({ contentType: "application/json", body: JSON.stringify(confirmed) });
|
|
150
|
+
});
|
|
151
|
+
await page.locator("input[name='title']").fill("Browser-only title");
|
|
152
|
+
await page.locator("button[type='submit']").click();
|
|
153
|
+
await page.waitForFunction(() => (
|
|
154
|
+
document.querySelector("input[name='title']")?.value === "Server-confirmed title"
|
|
155
|
+
));
|
|
156
|
+
assert.equal(await page.locator("input[name='title']").inputValue(), "Server-confirmed title");
|
|
157
|
+
await page.unroute(`${origin}/`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function assertReactReportsTransportFailure(page, origin, errors) {
|
|
161
|
+
const errorCount = errors.length;
|
|
162
|
+
await page.route(`${origin}/`, async (route) => {
|
|
163
|
+
if (route.request().method() !== "POST") return route.continue();
|
|
164
|
+
await route.abort("connectionfailed");
|
|
165
|
+
});
|
|
166
|
+
await page.locator("input[name='title']").fill("Unsaved browser title");
|
|
167
|
+
await page.locator("button[type='submit']").click();
|
|
168
|
+
await page.locator("[data-emseepea-part='status']").filter({ hasText: "could not be updated" }).waitFor();
|
|
169
|
+
assert.equal(await page.locator("input[name='title']").inputValue(), "Unsaved browser title");
|
|
170
|
+
assert.equal(await page.locator("[data-emseepea-part='view']").getAttribute("data-emseepea-state"), "ready");
|
|
171
|
+
await page.unroute(`${origin}/`);
|
|
172
|
+
assert.ok(errors.splice(errorCount).every((message) => message.includes("net::ERR_CONNECTION_FAILED")));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function startExample(serverUrl) {
|
|
176
|
+
const child = spawn(process.execPath, [fileURLToPath(serverUrl)], {
|
|
177
|
+
env: { ...process.env, NODE_ENV: "test", PORT: "0" },
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
});
|
|
180
|
+
let output = "";
|
|
181
|
+
let errors = "";
|
|
182
|
+
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
183
|
+
child.stderr.on("data", (chunk) => { errors += chunk; });
|
|
184
|
+
const mcpUrl = await new Promise((resolve, reject) => {
|
|
185
|
+
const timer = setTimeout(() => reject(new Error(`Example startup timed out: ${errors}`)), 15_000);
|
|
186
|
+
const inspect = () => {
|
|
187
|
+
const match = output.match(/http:\/\/127\.0\.0\.1:\d+\/mcp/);
|
|
188
|
+
if (match) {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolve(match[0]);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
child.stdout.on("data", inspect);
|
|
194
|
+
child.once("error", reject);
|
|
195
|
+
child.once("exit", (code) => reject(new Error(`Example exited ${code}: ${errors}`)));
|
|
196
|
+
inspect();
|
|
197
|
+
});
|
|
198
|
+
return { child, origin: new URL(mcpUrl).origin };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function stopExample(child) {
|
|
202
|
+
if (child.exitCode !== null) return;
|
|
203
|
+
child.kill("SIGTERM");
|
|
204
|
+
await Promise.race([
|
|
205
|
+
new Promise((resolve) => child.once("close", resolve)),
|
|
206
|
+
new Promise((resolve) => setTimeout(resolve, 3_000)),
|
|
207
|
+
]);
|
|
208
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
209
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"jsx": "react-jsx",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"target": "ES2023",
|
|
10
|
+
"types": [
|
|
11
|
+
"node",
|
|
12
|
+
"react",
|
|
13
|
+
"react-dom"
|
|
14
|
+
],
|
|
15
|
+
"verbatimModuleSyntax": true
|
|
16
|
+
},
|
|
17
|
+
"include": [
|
|
18
|
+
"src/**/*.tsx"
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emseepea/create-react-ui-server",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Create an Em See Pea server with an accessible React form.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/emseepea/emseepea.git",
|
|
10
|
+
"directory": "packages/create-react-ui-server"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://emseepea.github.io/emseepea/examples/",
|
|
13
|
+
"bugs": "https://github.com/emseepea/emseepea/issues",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"provenance": false,
|
|
17
|
+
"tag": "next"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"create-react-ui-server": "./dist/create.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
}
|
|
29
|
+
}
|