netjes 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +126 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +19 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +17 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +171 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +273 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +126 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +49 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +405 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- package/vite/plugin.mjs +291 -0
package/src/vitals.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// netjes/vitals: Core Web Vitals, measured with the web-vitals package (an optional peer dependency of netjes).
|
|
2
|
+
import { useContext, useEffect, useRef } from 'react';
|
|
3
|
+
import { type Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
|
|
4
|
+
import { usePagePayload } from './router.tsx';
|
|
5
|
+
import { VitalsEndpointContext } from './vitals-config.ts';
|
|
6
|
+
|
|
7
|
+
/** One measurement, as `web-vitals` reports it, with the route pattern of the page it was measured on. */
|
|
8
|
+
export type WebVitalsMetric = Pick<Metric, 'name' | 'value' | 'rating' | 'delta' | 'id' | 'navigationType'> & {
|
|
9
|
+
route: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Subscriber = (metric: WebVitalsMetric) => void;
|
|
13
|
+
|
|
14
|
+
const subscribers = new Set<Subscriber>();
|
|
15
|
+
const reported: WebVitalsMetric[] = [];
|
|
16
|
+
let started = false;
|
|
17
|
+
|
|
18
|
+
/** Web vitals describe the page load, so they are measured once, for the route the document was loaded on. */
|
|
19
|
+
function start(route: string | null) {
|
|
20
|
+
if (started) return;
|
|
21
|
+
started = true;
|
|
22
|
+
const report = ({ name, value, rating, delta, id, navigationType }: Metric) => {
|
|
23
|
+
const metric: WebVitalsMetric = { name, value, rating, delta, id, navigationType, route };
|
|
24
|
+
reported.push(metric);
|
|
25
|
+
for (const subscriber of subscribers) subscriber(metric);
|
|
26
|
+
};
|
|
27
|
+
onCLS(report);
|
|
28
|
+
onFCP(report);
|
|
29
|
+
onINP(report);
|
|
30
|
+
onLCP(report);
|
|
31
|
+
onTTFB(report);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Like Next.js's `useReportWebVitals`: calls back with each Core Web Vital of this page load (also ones measured earlier). */
|
|
35
|
+
export function useReportWebVitals(callback: (metric: WebVitalsMetric) => void) {
|
|
36
|
+
const { route } = usePagePayload();
|
|
37
|
+
const latest = useRef(callback);
|
|
38
|
+
latest.current = callback;
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const subscriber: Subscriber = (metric) => latest.current(metric);
|
|
42
|
+
for (const metric of reported) subscriber(metric);
|
|
43
|
+
subscribers.add(subscriber);
|
|
44
|
+
start(route);
|
|
45
|
+
return () => {
|
|
46
|
+
subscribers.delete(subscriber);
|
|
47
|
+
};
|
|
48
|
+
// Subscribes once; the route is the one the page was loaded on.
|
|
49
|
+
}, []);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The measurements a `<WebVitals />` has sent, so one that mounts again does not send the replayed ones twice. */
|
|
53
|
+
const sent = new WeakSet<WebVitalsMetric>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sends Core Web Vitals to the server, which records them as `netjes.web_vitals.*` histograms (turn on
|
|
57
|
+
* `NetjesOptions.CollectWebVitals`). Render it once, e.g. in the root layout.
|
|
58
|
+
*/
|
|
59
|
+
export function WebVitals() {
|
|
60
|
+
const endpoint = useContext(VitalsEndpointContext);
|
|
61
|
+
useReportWebVitals((metric) => {
|
|
62
|
+
if (!endpoint || sent.has(metric)) return;
|
|
63
|
+
sent.add(metric);
|
|
64
|
+
const { name, value, rating, route, navigationType } = metric;
|
|
65
|
+
navigator.sendBeacon(endpoint, JSON.stringify({ name, value, rating, route, navigationType }));
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
package/src/zod.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// zod helpers (netjes/zod). Only this module imports zod, so apps on another schema library do not need it.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What `deferred()` parses to: a thenable that React's `use()` reads (it records `status` and `value` as React
|
|
6
|
+
* expects). Not a Promise, because zod would then treat the whole schema as async.
|
|
7
|
+
*/
|
|
8
|
+
export class DeferredValue<T> implements PromiseLike<T> {
|
|
9
|
+
status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
|
|
10
|
+
value?: T;
|
|
11
|
+
reason?: unknown;
|
|
12
|
+
private readonly settled: Promise<T>;
|
|
13
|
+
|
|
14
|
+
constructor(source: PromiseLike<unknown>, parse: (value: unknown) => T) {
|
|
15
|
+
this.settled = Promise.resolve(source).then(
|
|
16
|
+
(raw) => {
|
|
17
|
+
try {
|
|
18
|
+
const value = parse(raw);
|
|
19
|
+
this.status = 'fulfilled';
|
|
20
|
+
this.value = value;
|
|
21
|
+
return value;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
this.status = 'rejected';
|
|
24
|
+
this.reason = error;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
(reason) => {
|
|
29
|
+
this.status = 'rejected';
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
throw reason;
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
// Values nobody reads must not surface as unhandled rejections.
|
|
35
|
+
this.settled.catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
then<R1 = T, R2 = never>(
|
|
39
|
+
onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
|
|
40
|
+
onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
41
|
+
): PromiseLike<R1 | R2> {
|
|
42
|
+
return this.settled.then(onFulfilled, onRejected);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const validated = new WeakMap<object, Map<z.ZodType, DeferredValue<unknown>>>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A `Page.Defer` value in page or layout props: a promise of `schema`'s output. Read it with React's `use()` inside
|
|
50
|
+
* a Suspense boundary. The resolved value is validated; a mismatch rejects (and reaches the error boundary).
|
|
51
|
+
*/
|
|
52
|
+
export function deferred<Schema extends z.ZodType>(schema: Schema): z.ZodType<PromiseLike<z.output<Schema>>, Promise<unknown>> {
|
|
53
|
+
const parsed = z
|
|
54
|
+
.custom<Promise<unknown>>((value) => value instanceof Promise, { message: 'Expected a deferred value (Page.Defer)' })
|
|
55
|
+
.transform((promise): DeferredValue<z.output<Schema>> => {
|
|
56
|
+
// The same thenable every time, so use() does not suspend again after each render.
|
|
57
|
+
let bySchema = validated.get(promise);
|
|
58
|
+
if (!bySchema) validated.set(promise, (bySchema = new Map()));
|
|
59
|
+
let result = bySchema.get(schema);
|
|
60
|
+
if (!result) {
|
|
61
|
+
result = new DeferredValue(promise, (value) => {
|
|
62
|
+
const parsed = schema.safeParse(value);
|
|
63
|
+
if (parsed.success) return parsed.data;
|
|
64
|
+
throw new Error(`Invalid deferred value: ${parsed.error.issues.map((i) => `${i.path.join('.') || '(value)'}: ${i.message}`).join('; ')}`);
|
|
65
|
+
});
|
|
66
|
+
bySchema.set(schema, result);
|
|
67
|
+
}
|
|
68
|
+
return result as DeferredValue<z.output<Schema>>;
|
|
69
|
+
});
|
|
70
|
+
// zod types a transform returning a thenable as its awaited value; it is the thenable.
|
|
71
|
+
return parsed as unknown as z.ZodType<PromiseLike<z.output<Schema>>, Promise<unknown>>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
# docker compose up -> dev (dotnet watch + Vite HMR)
|
|
3
|
+
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -> production image
|
|
4
|
+
# Builds against the published Netjes NuGet and npm packages, as any app made by `npx netjes new` does. Only an app
|
|
5
|
+
# scaffolded from a Netjes checkout references the checkout, which this build cannot see: scaffold it with --packages.
|
|
6
|
+
ARG NODE_IMAGE=node:24.22.1-bookworm-slim
|
|
7
|
+
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0.403-noble
|
|
8
|
+
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0.14-noble
|
|
9
|
+
|
|
10
|
+
FROM ${NODE_IMAGE} AS node
|
|
11
|
+
|
|
12
|
+
# npm dependencies, cached until package.json or the lockfile changes.
|
|
13
|
+
FROM node AS deps
|
|
14
|
+
WORKDIR /src
|
|
15
|
+
COPY package.json package-lock.json ./
|
|
16
|
+
RUN npm ci
|
|
17
|
+
|
|
18
|
+
# Development: the source is bind-mounted by docker-compose.override.yml.
|
|
19
|
+
FROM ${SDK_IMAGE} AS dev
|
|
20
|
+
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
21
|
+
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
|
22
|
+
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
|
23
|
+
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
|
24
|
+
WORKDIR /src
|
|
25
|
+
COPY --from=deps /src ./
|
|
26
|
+
ENV ASPNETCORE_ENVIRONMENT=Development \
|
|
27
|
+
ASPNETCORE_URLS=http://+:8080 \
|
|
28
|
+
DOTNET_USE_POLLING_FILE_WATCHER=1 \
|
|
29
|
+
DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH=1 \
|
|
30
|
+
NETJES_VITE_HOST=0.0.0.0
|
|
31
|
+
EXPOSE 8080 5173
|
|
32
|
+
CMD ["dotnet", "watch", "run", "--no-launch-profile"]
|
|
33
|
+
|
|
34
|
+
# Client bundle (dist/client) and server bundle + sidecar (dist/server).
|
|
35
|
+
FROM deps AS client
|
|
36
|
+
COPY . .
|
|
37
|
+
RUN npm run build
|
|
38
|
+
|
|
39
|
+
FROM ${SDK_IMAGE} AS build
|
|
40
|
+
WORKDIR /src
|
|
41
|
+
COPY __NAME__.csproj ./
|
|
42
|
+
RUN dotnet restore
|
|
43
|
+
COPY . .
|
|
44
|
+
COPY --from=client /src/dist dist
|
|
45
|
+
RUN dotnet publish -c Release -o /app --no-restore -p:NetjesSkipClientBuild=true
|
|
46
|
+
|
|
47
|
+
# Production: ASP.NET Core runtime plus the node binary for the SSR sidecar. Runs as the non-root app user.
|
|
48
|
+
FROM ${RUNTIME_IMAGE} AS production
|
|
49
|
+
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
50
|
+
# Where ASP.NET Core keeps its Data Protection keys; docker-compose.prod.yml mounts a volume here.
|
|
51
|
+
RUN mkdir -p /home/app/.aspnet/DataProtection-Keys && chown -R app:app /home/app/.aspnet
|
|
52
|
+
WORKDIR /app
|
|
53
|
+
COPY --from=build /app ./
|
|
54
|
+
USER $APP_UID
|
|
55
|
+
EXPOSE 8080
|
|
56
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
|
|
57
|
+
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
58
|
+
ENTRYPOINT ["dotnet", "__NAME__.dll"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
using Microsoft.AspNetCore.Mvc;
|
|
2
|
+
|
|
3
|
+
namespace __NAME__.Features.Home;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// The home page's loader (the C# counterpart of getServerSideProps) and a server action. Their types are generated
|
|
7
|
+
/// as zod schemas and typed references in generated/netjes.ts.
|
|
8
|
+
/// </summary>
|
|
9
|
+
public static class HomePage
|
|
10
|
+
{
|
|
11
|
+
public sealed record HomeProps(string Message, DateTimeOffset RenderedAt);
|
|
12
|
+
|
|
13
|
+
// Form inputs have init properties: a field the post leaves out, such as an unchecked checkbox, keeps its default,
|
|
14
|
+
// where form binding would refuse the post for a positional record.
|
|
15
|
+
public sealed record GreetInput
|
|
16
|
+
{
|
|
17
|
+
public string? Name { get; init; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public sealed record Greeting(string Text);
|
|
21
|
+
|
|
22
|
+
public static void MapHomePage(this IEndpointRouteBuilder app)
|
|
23
|
+
{
|
|
24
|
+
app.MapPageLoader("/", (TimeProvider time) => Page.Props(new HomeProps("Hello from C#", time.GetUtcNow())));
|
|
25
|
+
|
|
26
|
+
// Called from components/HelloForm.tsx through serverActions['home.greet'].
|
|
27
|
+
app.MapAction("home.greet", ([FromForm] GreetInput input) =>
|
|
28
|
+
string.IsNullOrWhiteSpace(input.Name)
|
|
29
|
+
? ServerAction.Invalid("name", "Tell us your name.")
|
|
30
|
+
: ServerAction.Ok(new Greeting($"Hello, {input.Name.Trim()}!")));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
using __NAME__;
|
|
2
|
+
using __NAME__.Features.Home;
|
|
3
|
+
|
|
4
|
+
var builder = WebApplication.CreateBuilder(args);
|
|
5
|
+
|
|
6
|
+
builder.Services.AddNetjes(options =>
|
|
7
|
+
// Available in every page and layout through useSharedProps(sharedProps); typed in generated/netjes.ts.
|
|
8
|
+
options.UseSharedProps(_ => ValueTask.FromResult(new SiteProps("__TITLE__"))));
|
|
9
|
+
builder.Services.AddHealthChecks().AddNetjes();
|
|
10
|
+
|
|
11
|
+
var app = builder.Build();
|
|
12
|
+
|
|
13
|
+
if (!app.Environment.IsDevelopment())
|
|
14
|
+
{
|
|
15
|
+
// Once the site is served over https: HSTS on https responses, and http redirected to https. Behind a proxy
|
|
16
|
+
// that terminates TLS, add UseForwardedHeaders() first so the app sees the original scheme.
|
|
17
|
+
app.UseHsts();
|
|
18
|
+
app.UseHttpsRedirection();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
app.UseNetjes();
|
|
22
|
+
app.UseAntiforgery(); // after authentication, if you add it
|
|
23
|
+
|
|
24
|
+
app.MapHealthChecks("/healthz");
|
|
25
|
+
app.MapHomePage();
|
|
26
|
+
app.MapPages(); // every other page file, plus the 404 fallback
|
|
27
|
+
|
|
28
|
+
app.Run();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
3
|
+
"profiles": {
|
|
4
|
+
"http": {
|
|
5
|
+
"commandName": "Project",
|
|
6
|
+
"launchBrowser": false,
|
|
7
|
+
"applicationUrl": "http://localhost:5080",
|
|
8
|
+
"environmentVariables": {
|
|
9
|
+
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# __TITLE__
|
|
2
|
+
|
|
3
|
+
A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod and Vitest are set up.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run dev # dotnet watch + Vite HMR, http://localhost:5080
|
|
7
|
+
npm test # Vitest component tests
|
|
8
|
+
npm run typecheck
|
|
9
|
+
npm run routes # the route table, from C#
|
|
10
|
+
npx netjes build # types, client and server bundles
|
|
11
|
+
npx netjes info # check Node.js, the .NET SDK, the packages and this setup
|
|
12
|
+
dotnet publish -c Release
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `app/` holds the pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `layout.tsx` wraps them all, `not-found.tsx` and `error.tsx` handle the rest.
|
|
16
|
+
- `public/` is served at the site root. `public/favicon.svg`, which the metadata in `app/layout.tsx` links, is the Netjes mark: replace it with the app's own.
|
|
17
|
+
- `Features/` holds the C# side, one folder per feature: `Features/Home/HomePage.cs` maps the home page's loader and the `home.greet` server action the form calls.
|
|
18
|
+
- `AGENTS.md` tells coding agents how the app is laid out and what the rules are; `CLAUDE.md` imports it for Claude Code.
|
|
19
|
+
- `Properties/launchSettings.json` holds the `http` launch profile that `dotnet run` and IDEs (F5) use: the app in Development on http://localhost:5080, as `npm run dev` runs it but without `dotnet watch`.
|
|
20
|
+
- `generated/netjes.ts` holds zod schemas for what the loaders and actions return, typed server action references and typed routes. The app rewrites it when it starts in Development; commit it, and run `npx netjes generate --check` in CI.
|
|
21
|
+
|
|
22
|
+
Outside Development, `Program.cs` sends HSTS on https responses and redirects http to https once the app knows its https port (`ASPNETCORE_HTTPS_PORT` or `ASPNETCORE_HTTPS_PORTS`). Behind a proxy that terminates TLS, add `UseForwardedHeaders()` (trusting only that proxy) so proxied requests count as https and get HSTS, and let the proxy redirect plain http. Netjes adds `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options: SAMEORIGIN` to every page; see `NetjesOptions.SecurityHeaders` to change that.
|
|
23
|
+
|
|
24
|
+
With Docker: `docker compose up` for development, `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` for production. In Development `appsettings.Development.json` limits `AllowedHosts` to loopback names (a DNS-rebinding guard); add a name there if you reach the dev app by another host. The Docker build restores Netjes from NuGet and npm, which is how any app made by `npx netjes new` references it. Only an app scaffolded from a Netjes checkout references the checkout instead; scaffold it with `netjes new --packages` to build it in Docker.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# __TITLE__
|
|
2
|
+
|
|
3
|
+
How this app is built, for coding agents and the people working with them. It is a [Netjes](https://github.com/DennisVerhoeven/netjes) app: ASP.NET Core receives every request and owns routing, data loading, server actions, authorization and caching; a Node sidecar renders the React pages; the browser hydrates them and navigates on the client.
|
|
4
|
+
|
|
5
|
+
## Netjes docs
|
|
6
|
+
|
|
7
|
+
- https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms.txt: an index of the docs; https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms-full.txt has the ones for building apps in one file. For people, the same docs are at https://github.com/DennisVerhoeven/netjes/blob/main/docs/README.md.
|
|
8
|
+
- `.claude/skills/netjes/`: the conventions in short, with references for routing, data and actions, caching and streaming, testing and deployment. Read `SKILL.md` before changing pages, loaders or actions.
|
|
9
|
+
- The docs on main can be ahead of the version this app uses (`npx netjes --version`). The installed package, with its types, is in `node_modules/netjes/`.
|
|
10
|
+
|
|
11
|
+
## Layout
|
|
12
|
+
|
|
13
|
+
| Path | What |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `app/` | Pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `blog/[slug]/page.tsx` is `/blog/{slug}`. `layout.tsx` wraps the pages below it and stays mounted; `loading.tsx`, `error.tsx`, `not-found.tsx`, `forbidden.tsx` and `unauthorized.tsx` are boundaries. `(group)` folders add no URL segment, `_private` folders are not routed. |
|
|
16
|
+
| `Features/` | The C# side, one folder per feature (`namespace __NAME__.Features.Home`): page loaders (`MapPageLoader`), layout loaders (`MapLayoutLoader`), server actions (`MapAction`) and JSON endpoints, with records for what they take and return. Each feature has a `Map...` extension method that `Program.cs` calls. |
|
|
17
|
+
| `Program.cs` | Services and the pipeline: `AddNetjes`, `UseNetjes`, authentication (when you add it), `UseAntiforgery`, each feature's `Map...`, and `app.MapPages()` last. |
|
|
18
|
+
| `components/` | React components, with their Vitest tests next to them (`*.test.tsx`). |
|
|
19
|
+
| `generated/netjes.ts` | Generated from the C# types and the page files: zod schemas for page and layout props (`pages`, `layouts`), `sharedProps`, typed `serverActions`, `api` schemas for JSON endpoints, `route()` and the `Href` type. Never edit it. |
|
|
20
|
+
| `styles/global.css` | Tailwind CSS (`@import 'tailwindcss'`), imported by `app/layout.tsx`. |
|
|
21
|
+
| `public/` | Static files, served at the site root. `favicon.svg` is the Netjes mark, linked by `icons` in the root layout's `metadata`; replace it with the app's own. |
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run dev # dotnet watch + Vite HMR on http://localhost:5080; rewrites generated/netjes.ts on start
|
|
27
|
+
npm test # Vitest
|
|
28
|
+
npm run typecheck # strict TypeScript
|
|
29
|
+
dotnet build # C#, warnings are errors
|
|
30
|
+
npx netjes generate # rewrite generated/netjes.ts after changing a C# type (--check: fail when stale)
|
|
31
|
+
npm run routes # the route table as ASP.NET Core sees it, with route warnings
|
|
32
|
+
npx netjes build # types, then the client and server bundles
|
|
33
|
+
dotnet publish -c Release # runs the build and ships dist/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Adding a page with data
|
|
37
|
+
|
|
38
|
+
1. Write the C# loader in `Features/<Feature>/`: a record for the props and `app.MapPageLoader("/blog/[slug]", (string slug, PostStore posts) => ...)` returning `Page.Props(new PostProps(...))`, `Page.NotFound()` or `Page.Redirect(url)`. Route parameters bind by name, like any minimal API.
|
|
39
|
+
2. Call its `Map...` method from `Program.cs`, before `app.MapPages()`.
|
|
40
|
+
3. Run `npx netjes generate` (or restart `npm run dev`).
|
|
41
|
+
4. Write `app/blog/[slug]/page.tsx`: `export default definePage(pages['/blog/[slug]'], function Post({ post }) { ... })`.
|
|
42
|
+
5. Test the component with Vitest and `createTestApp` from `netjes/testing`; run the checks below.
|
|
43
|
+
|
|
44
|
+
A page without a loader is a plain default export and gets empty props. Forms call server actions: `app.MapAction("posts.like", ...)` in C#, `useAction(serverActions['posts.like'])` and `<Form action={formAction}>` in React; they also work without JavaScript.
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
- C# decides: data access, validation, authorization (`.RequireAuthorization()` on a loader, a layout loader or `MapPages()`; `Page.Forbidden()`), redirects and caching. React renders what the loader sends; hiding a button is not access control.
|
|
49
|
+
- Never edit `generated/netjes.ts`. Change the C# type and regenerate; commit the file with the change. CI runs `npx netjes generate --check`.
|
|
50
|
+
- zod at every boundary: `definePage` and `defineLayout` with the generated schemas, `useSearchParams(schema)` and `useParams(schema)` for the URL, `api['GET /api/...'].response.parse(...)` for JSON you fetch, `usePublicConfig(schema)` for public config, `Href` (or `isHref`) from the generated file for URLs that come from data. No `any`, no casts of server data.
|
|
51
|
+
- Server state goes through TanStack Query (`useQuery`), not `useEffect` and `fetch`. Seed it from the loader with `.WithQuery(key, data)` so it is server-rendered; actions update it with `.Invalidate(key)` or `.WithQuery(key, data)`.
|
|
52
|
+
- Internal links are `<Link href={route('/blog/[slug]', { slug })}>`; a typo in an href is a type error.
|
|
53
|
+
- Action inputs are records with `init` properties: `public sealed record NoteInput { [Required] public string? Text { get; init; } public bool Pinned { get; init; } }`. A field the post leaves out, such as an unchecked checkbox, keeps its default; form binding refuses (400) a post that lacks one of a positional record's constructor fields. DataAnnotations on them (`[Required]`, `[StringLength]`) are checked before the action runs and come back as field errors, with no setup, also for a post that has none of the input's fields. Return `ServerAction.Invalid(field, message)` for other errors the user can fix.
|
|
54
|
+
- `.CachePage(...)` only for pages that are the same for every visitor: their loaders and shared props must not depend on cookies or other per-visitor state.
|
|
55
|
+
- `Netjes:PublicConfig` is sent to the browser: never put secrets there.
|
|
56
|
+
- Write the failing test first, then the change.
|
|
57
|
+
|
|
58
|
+
## Before you finish
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm test && npm run typecheck && dotnet build && npx netjes generate --check
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then `npm run routes` should print no warnings, and the page should work in `npm run dev`, including with JavaScript turned off for forms.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
2
|
+
|
|
3
|
+
<PropertyGroup>
|
|
4
|
+
<TargetFramework>net10.0</TargetFramework>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
7
|
+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
|
|
10
|
+
<!--NETJES_REFERENCES-->
|
|
11
|
+
</Project>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Metadata } from 'netjes';
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = { title: 'About' };
|
|
4
|
+
|
|
5
|
+
// No loader is mapped for this page, so it renders with empty props.
|
|
6
|
+
export default function About() {
|
|
7
|
+
return (
|
|
8
|
+
<article className="space-y-4">
|
|
9
|
+
<h1 className="text-3xl font-bold tracking-tight">About</h1>
|
|
10
|
+
<p className="text-slate-600">
|
|
11
|
+
Pages are React components in <code>app/</code>, as in the Next.js App Router. ASP.NET Core routes every
|
|
12
|
+
request, runs the page's C# loader and renders the page on the server; after that, links navigate in the
|
|
13
|
+
browser, and forms call C# server actions.
|
|
14
|
+
</p>
|
|
15
|
+
</article>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ErrorPageProps } from 'netjes';
|
|
2
|
+
|
|
3
|
+
/** Errors below the root layout land here; the layout stays usable. */
|
|
4
|
+
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
|
5
|
+
return (
|
|
6
|
+
<section role="alert" className="space-y-4">
|
|
7
|
+
<h1 className="text-3xl font-bold tracking-tight">Something went wrong</h1>
|
|
8
|
+
{/* In development the message can take several lines: a syntax error comes with a code frame. */}
|
|
9
|
+
<p className="whitespace-pre-wrap wrap-anywhere text-slate-600">{error.message}</p>
|
|
10
|
+
<button type="button" onClick={reset} className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white">
|
|
11
|
+
Try again
|
|
12
|
+
</button>
|
|
13
|
+
</section>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Link, type LayoutProps, type Metadata, useSharedProps } from 'netjes';
|
|
2
|
+
import { sharedProps } from '../generated/netjes.ts';
|
|
3
|
+
import '../styles/global.css';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: { default: '__TITLE__', template: '%s · __TITLE__' },
|
|
7
|
+
icons: { icon: '/favicon.svg' },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const navLink = 'text-slate-500 no-underline hover:text-slate-900 aria-[current=page]:text-slate-900';
|
|
11
|
+
|
|
12
|
+
/** The root layout: wraps every page and stays mounted across navigations. */
|
|
13
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
14
|
+
const site = useSharedProps(sharedProps);
|
|
15
|
+
return (
|
|
16
|
+
<div className="mx-auto min-h-screen max-w-3xl px-6 py-10 font-sans text-slate-900">
|
|
17
|
+
<header className="mb-12 flex items-baseline justify-between">
|
|
18
|
+
<Link href="/" className="text-xl font-semibold no-underline">
|
|
19
|
+
{site.siteName}
|
|
20
|
+
</Link>
|
|
21
|
+
<nav aria-label="Main" className="flex gap-5">
|
|
22
|
+
<Link href="/" className={navLink}>
|
|
23
|
+
Home
|
|
24
|
+
</Link>
|
|
25
|
+
<Link href="/about" className={navLink}>
|
|
26
|
+
About
|
|
27
|
+
</Link>
|
|
28
|
+
</nav>
|
|
29
|
+
</header>
|
|
30
|
+
<main>{children}</main>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Link, type Metadata, usePathname } from 'netjes';
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = { title: 'Not found' };
|
|
4
|
+
|
|
5
|
+
export default function NotFound() {
|
|
6
|
+
return (
|
|
7
|
+
<section className="space-y-4">
|
|
8
|
+
<h1 className="text-3xl font-bold tracking-tight">Nothing at {usePathname()}</h1>
|
|
9
|
+
<Link href="/" className="underline">
|
|
10
|
+
Back to the home page
|
|
11
|
+
</Link>
|
|
12
|
+
</section>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { definePage } from 'netjes';
|
|
2
|
+
import { Greeting } from '../components/Greeting.tsx';
|
|
3
|
+
import { HelloForm } from '../components/HelloForm.tsx';
|
|
4
|
+
import { pages } from '../generated/netjes.ts';
|
|
5
|
+
|
|
6
|
+
// The props schema is generated from the C# HomeProps record (Features/Home/HomePage.cs).
|
|
7
|
+
export default definePage(pages['/'], function Home({ message, renderedAt }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="space-y-12">
|
|
10
|
+
<Greeting message={message} renderedAt={renderedAt} />
|
|
11
|
+
<HelloForm />
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createTestApp } from 'netjes/testing';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { Greeting } from './Greeting.tsx';
|
|
6
|
+
|
|
7
|
+
describe('Greeting', () => {
|
|
8
|
+
it('shows the message from the loader and counts clicks', async () => {
|
|
9
|
+
render(<Greeting message="Hello" renderedAt="2026-10-28T12:00:00Z" />, { wrapper: createTestApp().Wrapper });
|
|
10
|
+
|
|
11
|
+
await userEvent.click(screen.getByRole('button', { name: 'Clicked 0 times' }));
|
|
12
|
+
|
|
13
|
+
expect(screen.getByRole('heading', { name: 'Hello' })).toBeDefined();
|
|
14
|
+
expect(screen.getByRole('button', { name: 'Clicked 1 time' })).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('links to the about page without a full page load', async () => {
|
|
18
|
+
const app = createTestApp();
|
|
19
|
+
render(<Greeting message="Hello" renderedAt="2026-10-28T12:00:00Z" />, { wrapper: app.Wrapper });
|
|
20
|
+
|
|
21
|
+
await userEvent.click(screen.getByRole('link', { name: 'About this app' }));
|
|
22
|
+
|
|
23
|
+
expect(app.navigations).toEqual([{ type: 'push', href: '/about' }]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Link } from 'netjes';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export function Greeting({ message, renderedAt }: { message: string; renderedAt: string }) {
|
|
5
|
+
const [clicks, setClicks] = useState(0);
|
|
6
|
+
return (
|
|
7
|
+
<section className="space-y-5">
|
|
8
|
+
<h1 className="text-4xl font-bold tracking-tight">{message}</h1>
|
|
9
|
+
<p className="text-slate-600">
|
|
10
|
+
Rendered by ASP.NET Core at <time dateTime={renderedAt}>{renderedAt}</time>.
|
|
11
|
+
</p>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={() => setClicks((count) => count + 1)}
|
|
15
|
+
className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-700"
|
|
16
|
+
>
|
|
17
|
+
Clicked {clicks} {clicks === 1 ? 'time' : 'times'}
|
|
18
|
+
</button>
|
|
19
|
+
<p>
|
|
20
|
+
<Link href="/about" className="underline">
|
|
21
|
+
About this app
|
|
22
|
+
</Link>
|
|
23
|
+
</p>
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createTestApp } from 'netjes/testing';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { HelloForm } from './HelloForm.tsx';
|
|
6
|
+
|
|
7
|
+
describe('HelloForm', () => {
|
|
8
|
+
it('shows the greeting the server action returns', async () => {
|
|
9
|
+
const app = createTestApp({ actions: { 'home.greet': () => ({ ok: true, result: { text: 'Hello, Ada!' } }) } });
|
|
10
|
+
render(<HelloForm />, { wrapper: app.Wrapper });
|
|
11
|
+
|
|
12
|
+
await userEvent.type(screen.getByLabelText('Your name'), 'Ada');
|
|
13
|
+
await userEvent.click(screen.getByRole('button', { name: 'Say hello' }));
|
|
14
|
+
|
|
15
|
+
expect((await screen.findByRole('status')).textContent).toBe('Hello, Ada!');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('shows the validation error from C# next to the field', async () => {
|
|
19
|
+
const app = createTestApp({ actions: { 'home.greet': () => ({ ok: false, status: 422, errors: { name: ['Tell us your name.'] } }) } });
|
|
20
|
+
render(<HelloForm />, { wrapper: app.Wrapper });
|
|
21
|
+
|
|
22
|
+
await userEvent.click(screen.getByRole('button', { name: 'Say hello' }));
|
|
23
|
+
|
|
24
|
+
expect(await screen.findByText('Tell us your name.')).toBeDefined();
|
|
25
|
+
expect(screen.getByLabelText('Your name').getAttribute('aria-invalid')).toBe('true');
|
|
26
|
+
});
|
|
27
|
+
});
|