@evinvest/kitstart 0.1.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 +245 -0
- package/dist/cli/kitstart-size.d.ts +2 -0
- package/dist/cli/kitstart-size.js +11 -0
- package/dist/cli/size.d.ts +31 -0
- package/dist/cli/size.js +99 -0
- package/dist/core/accept.d.ts +49 -0
- package/dist/core/accept.js +78 -0
- package/dist/core/analytics.d.ts +43 -0
- package/dist/core/analytics.js +42 -0
- package/dist/core/antispam.d.ts +60 -0
- package/dist/core/antispam.js +90 -0
- package/dist/core/content.d.ts +82 -0
- package/dist/core/content.js +12 -0
- package/dist/core/lead.d.ts +88 -0
- package/dist/core/lead.js +42 -0
- package/dist/core/memo.d.ts +8 -0
- package/dist/core/memo.js +19 -0
- package/dist/core/place/index.d.ts +5 -0
- package/dist/core/place/index.js +5 -0
- package/dist/core/place/live.d.ts +30 -0
- package/dist/core/place/live.js +132 -0
- package/dist/core/place/publication.d.ts +29 -0
- package/dist/core/place/publication.js +42 -0
- package/dist/core/place/rating.d.ts +16 -0
- package/dist/core/place/rating.js +33 -0
- package/dist/core/place/types.d.ts +91 -0
- package/dist/core/place/types.js +27 -0
- package/dist/core/place/view.d.ts +56 -0
- package/dist/core/place/view.js +43 -0
- package/dist/core/routing.d.ts +121 -0
- package/dist/core/routing.js +198 -0
- package/dist/core/seo/ld.d.ts +58 -0
- package/dist/core/seo/ld.js +161 -0
- package/dist/core/seo/sitemap.d.ts +52 -0
- package/dist/core/seo/sitemap.js +60 -0
- package/dist/core/site.d.ts +118 -0
- package/dist/core/site.js +84 -0
- package/dist/core/status.d.ts +47 -0
- package/dist/core/status.js +62 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/next/config/build-env.d.ts +32 -0
- package/dist/next/config/build-env.js +101 -0
- package/dist/next/config/index.d.ts +30 -0
- package/dist/next/config/index.js +38 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +12 -0
- package/dist/next/load.d.ts +24 -0
- package/dist/next/load.js +36 -0
- package/dist/next/metadata.d.ts +31 -0
- package/dist/next/metadata.js +66 -0
- package/dist/next/og-palette.d.ts +14 -0
- package/dist/next/og-palette.js +14 -0
- package/dist/next/og.d.ts +31 -0
- package/dist/next/og.js +42 -0
- package/dist/next/quote-route.d.ts +36 -0
- package/dist/next/quote-route.js +124 -0
- package/dist/next/routes.d.ts +15 -0
- package/dist/next/routes.js +50 -0
- package/dist/proxy/index.d.ts +31 -0
- package/dist/proxy/index.js +108 -0
- package/dist/react/AnalyticsBoundary.d.ts +14 -0
- package/dist/react/AnalyticsBoundary.js +55 -0
- package/dist/react/AreaChips.d.ts +8 -0
- package/dist/react/AreaChips.js +11 -0
- package/dist/react/CallBar.d.ts +29 -0
- package/dist/react/CallBar.js +7 -0
- package/dist/react/Coverage.d.ts +24 -0
- package/dist/react/Coverage.js +10 -0
- package/dist/react/Faq.d.ts +17 -0
- package/dist/react/Faq.js +5 -0
- package/dist/react/LangSwitch.d.ts +24 -0
- package/dist/react/LangSwitch.js +17 -0
- package/dist/react/MapFacade.d.ts +18 -0
- package/dist/react/MapFacade.js +8 -0
- package/dist/react/PlaceDirectory.d.ts +25 -0
- package/dist/react/PlaceDirectory.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +34 -0
- package/dist/react/QuoteFormShell.js +10 -0
- package/dist/react/StatusScreen.d.ts +36 -0
- package/dist/react/StatusScreen.js +22 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.js +29 -0
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +1 -0
- package/dist/server/boot.d.ts +11 -0
- package/dist/server/boot.js +20 -0
- package/dist/server/client-key.d.ts +22 -0
- package/dist/server/client-key.js +20 -0
- package/dist/server/env.d.ts +44 -0
- package/dist/server/env.js +73 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/lead-store-postgres.d.ts +14 -0
- package/dist/server/lead-store-postgres.js +23 -0
- package/dist/server/lead-store-sqlite.d.ts +21 -0
- package/dist/server/lead-store-sqlite.js +158 -0
- package/dist/server/lead-store.d.ts +20 -0
- package/dist/server/lead-store.js +43 -0
- package/dist/server/notify.d.ts +37 -0
- package/dist/server/notify.js +79 -0
- package/dist/server/place-source.d.ts +48 -0
- package/dist/server/place-source.js +107 -0
- package/dist/server/smtp.d.ts +38 -0
- package/dist/server/smtp.js +197 -0
- package/dist/testing/e2e.d.ts +52 -0
- package/dist/testing/e2e.js +57 -0
- package/dist/testing/fixtures.d.ts +11 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/landing-contract.d.ts +33 -0
- package/dist/testing/landing-contract.js +95 -0
- package/dist/testing/lead-store-contract.d.ts +13 -0
- package/dist/testing/lead-store-contract.js +55 -0
- package/dist/testing/served-files.d.ts +7 -0
- package/dist/testing/served-files.js +41 -0
- package/package.json +129 -0
- package/template/README.md +55 -0
- package/template/app/[locale]/[location]/layout.tsx +29 -0
- package/template/app/[locale]/[location]/page.tsx +18 -0
- package/template/app/[locale]/[location]/prices/page.tsx +26 -0
- package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
- package/template/app/[locale]/error.tsx +14 -0
- package/template/app/[locale]/layout.tsx +28 -0
- package/template/app/[locale]/not-found.tsx +18 -0
- package/template/app/[locale]/page.tsx +40 -0
- package/template/app/[locale]/thanks/page.tsx +21 -0
- package/template/app/global-not-found.tsx +28 -0
- package/template/app/globals.css +18 -0
- package/template/app/health/route.ts +6 -0
- package/template/app/og/route.tsx +27 -0
- package/template/app/quote/route.ts +17 -0
- package/template/app/robots.ts +6 -0
- package/template/app/sitemap.ts +8 -0
- package/template/assets/brand.toml +68 -0
- package/template/assets/card.toml +8 -0
- package/template/assets/fonts/Inter-OFL.txt +93 -0
- package/template/assets/fonts/Inter-Regular.ttf +0 -0
- package/template/assets/fonts/Inter-Regular.woff2 +0 -0
- package/template/assets/mark.svg +1 -0
- package/template/deploy/config.nix +24 -0
- package/template/eslint.config.mjs +28 -0
- package/template/flake.nix +46 -0
- package/template/instrumentation.ts +13 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +44 -0
- package/template/postcss.config.mjs +3 -0
- package/template/proxy.ts +11 -0
- package/template/src/entities/content/index.ts +12 -0
- package/template/src/entities/content/model/en.ts +47 -0
- package/template/src/entities/content/model/fr.ts +47 -0
- package/template/src/entities/content/model/types.ts +37 -0
- package/template/src/entities/place/api/load.ts +12 -0
- package/template/src/entities/place/index.ts +3 -0
- package/template/src/entities/place/server.ts +1 -0
- package/template/src/shared/config/env.ts +17 -0
- package/template/src/shared/config/i18n.ts +15 -0
- package/template/src/shared/config/lead.ts +15 -0
- package/template/src/shared/config/places.ts +20 -0
- package/template/src/shared/config/public.ts +11 -0
- package/template/src/shared/config/site.ts +39 -0
- package/template/src/shared/ui/fonts.ts +15 -0
- package/template/src/views/home/index.ts +1 -0
- package/template/src/views/home/ui/PlaceHome.tsx +76 -0
- package/template/src/views/not-found/index.ts +1 -0
- package/template/src/views/not-found/server.ts +1 -0
- package/template/src/views/not-found/ui/Gone.tsx +29 -0
- package/template/src/views/not-found/ui/NotFound.tsx +27 -0
- package/template/src/views/place/server.ts +13 -0
- package/template/src/views/server-error/index.ts +1 -0
- package/template/src/views/server-error/ui/ServerError.tsx +24 -0
- package/template/steiger.config.ts +26 -0
- package/template/tests/bundle_budget.txt +8 -0
- package/template/tests/contract.test.ts +14 -0
- package/template/tests/e2e/playwright.config.ts +26 -0
- package/template/tests/e2e/screenshot-section.css +5 -0
- package/template/tests/e2e/sections.spec.ts +12 -0
- package/template/tests/e2e/tsconfig.json +11 -0
- package/template/tests/empty.ts +1 -0
- package/template/tsconfig.json +23 -0
- package/template/vitest.config.ts +20 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
3
|
+
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
4
|
+
export function defaultLeadMail(brand, lead, id) {
|
|
5
|
+
const place = lead.placeSlug ?? "unknown";
|
|
6
|
+
return {
|
|
7
|
+
subject: `${brand.name} — new lead (${place})`,
|
|
8
|
+
text: [
|
|
9
|
+
`Lead #${id} — place ${place}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
|
|
10
|
+
"",
|
|
11
|
+
`Subject : ${lead.subject}`,
|
|
12
|
+
`Locality : ${lead.locality}`,
|
|
13
|
+
`Mobile : ${lead.mobile}`,
|
|
14
|
+
...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
|
|
15
|
+
].join("\n"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Checked when it is built — at boot, when a brand builds it there — not on
|
|
20
|
+
* the first lead: with SMTP configured, a sender needs a domain to be `leads@`
|
|
21
|
+
* of (or `LEAD_NOTIFY_FROM`), and a recipient needs the brand's mailbox (or
|
|
22
|
+
* `LEAD_NOTIFY_TO`). A site before launch has neither, and `leads@null` is a
|
|
23
|
+
* mail no relay delivers.
|
|
24
|
+
*/
|
|
25
|
+
/** Mails a notifier sends per minute, per process; past it the lead is only logged. */
|
|
26
|
+
export const MAIL_PER_MINUTE = 20;
|
|
27
|
+
export function leadNotifier(site, env, options = {}) {
|
|
28
|
+
const { brand } = site;
|
|
29
|
+
const log = options.log ?? console;
|
|
30
|
+
const now = options.now ?? Date.now;
|
|
31
|
+
const cap = options.mailPerMinute ?? MAIL_PER_MINUTE;
|
|
32
|
+
const format = options.format ?? ((lead, id) => defaultLeadMail(brand, lead, id));
|
|
33
|
+
let window = { start: Number.NEGATIVE_INFINITY, sent: 0 };
|
|
34
|
+
const spend = () => {
|
|
35
|
+
const t = now();
|
|
36
|
+
if (t - window.start >= 60_000)
|
|
37
|
+
window = { start: t, sent: 0 };
|
|
38
|
+
window.sent += 1;
|
|
39
|
+
return window.sent <= cap;
|
|
40
|
+
};
|
|
41
|
+
let mail = null;
|
|
42
|
+
if (env.smtpUrl) {
|
|
43
|
+
const from = env.notifyFrom ?? (brand.domain ? `leads@${brand.domain}` : null);
|
|
44
|
+
if (!from)
|
|
45
|
+
throw new Error("SMTP_URL is set but the site has no domain: set LEAD_NOTIFY_FROM");
|
|
46
|
+
const to = env.notifyTo ?? brand.email;
|
|
47
|
+
if (!to)
|
|
48
|
+
throw new Error("SMTP_URL is set but the brand has no email: set LEAD_NOTIFY_TO");
|
|
49
|
+
const helo = brand.domain ?? from.split("@")[1] ?? "localhost";
|
|
50
|
+
mail = { url: parseSmtpUrl(env.smtpUrl), from, to, helo };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
async notify(lead, id) {
|
|
54
|
+
const channels = [];
|
|
55
|
+
if (mail && !spend()) {
|
|
56
|
+
log.warn(`lead ${id}: over ${cap} notification mails a minute; stored, not mailed`);
|
|
57
|
+
}
|
|
58
|
+
else if (mail) {
|
|
59
|
+
const { subject, text } = format(lead, id);
|
|
60
|
+
channels.push(sendMail(mail.url, { from: mail.from, to: mail.to, subject, text }, { helo: mail.helo }));
|
|
61
|
+
}
|
|
62
|
+
if (env.smsToken) {
|
|
63
|
+
// No SMS provider is chosen; the token is read so a deployment can
|
|
64
|
+
// already carry it, and the lead is still mailed.
|
|
65
|
+
log.warn(`lead ${id}: SMS_TOKEN is set but no SMS provider is wired; not texted`);
|
|
66
|
+
}
|
|
67
|
+
if (channels.length === 0 && !mail) {
|
|
68
|
+
log.warn(`lead ${id}: no notification channel configured; the lead is stored and unsent`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (channels.length === 0)
|
|
72
|
+
return;
|
|
73
|
+
const results = await Promise.allSettled(channels);
|
|
74
|
+
const failure = results.find(r => r.status === "rejected");
|
|
75
|
+
if (failure)
|
|
76
|
+
throw failure.reason;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { Place } from "../core/place/types.js";
|
|
3
|
+
import type { Site } from "../core/site.js";
|
|
4
|
+
/**
|
|
5
|
+
* The live half of each place: `GET <base>/locations/<slug>?locale=<l>`,
|
|
6
|
+
* merged over the baked config.
|
|
7
|
+
*
|
|
8
|
+
* The invariant that shapes it: pages are cached (ISR), and under ISR a cold
|
|
9
|
+
* render that throws is Next's bare-text `Internal Server Error` — no phone,
|
|
10
|
+
* no error boundary. So a page's loader never throws on the source: a 5xx and
|
|
11
|
+
* an unreachable source both serve the baked place (a warm page keeps its last
|
|
12
|
+
* good render anyway). Only a 404 changes the answer — the place is gone. The
|
|
13
|
+
* sitemap is the one strict reader: see `listPlaces`.
|
|
14
|
+
*/
|
|
15
|
+
export interface PlaceSourceOptions {
|
|
16
|
+
/** The source's base URL, read when asked; `null` → the baked config only. */
|
|
17
|
+
baseUrl: () => string | null;
|
|
18
|
+
/**
|
|
19
|
+
* TTL on the fetch itself, not on the page: a segment-level `revalidate`
|
|
20
|
+
* does not reach an explicit `force-cache` fetch, which would then cache
|
|
21
|
+
* forever. The source sees one request per place per window.
|
|
22
|
+
*/
|
|
23
|
+
revalidateSeconds?: number;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
log?: Pick<Console, "error">;
|
|
26
|
+
}
|
|
27
|
+
export declare class PlaceSourceError extends Error {
|
|
28
|
+
readonly name = "PlaceSourceError";
|
|
29
|
+
}
|
|
30
|
+
export interface PlaceSource<L extends string> {
|
|
31
|
+
/** One place, or `null` when it does not exist — the caller's `notFound()`. Never throws on the source. */
|
|
32
|
+
getPlace(slug: string, locale: L): Promise<Place<L> | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Every place the source still has (a 404 is a place it retired). `"page"`
|
|
35
|
+
* stands on the baked places through an outage; `"sitemap"` throws, because
|
|
36
|
+
* a sitemap that silently drops places tells a crawler they are gone, and a
|
|
37
|
+
* 5xx tells it to keep its last copy.
|
|
38
|
+
*/
|
|
39
|
+
listPlaces(locale: L, mode: "page" | "sitemap"): Promise<Place<L>[]>;
|
|
40
|
+
/**
|
|
41
|
+
* One place, strictly — for a sitemap, which lists one host's place and
|
|
42
|
+
* must fail (a 5xx a crawler retries) rather than shrink. `null` only when
|
|
43
|
+
* the source withdrew it.
|
|
44
|
+
*/
|
|
45
|
+
getPlaceStrict(slug: string, locale: L): Promise<Place<L> | null>;
|
|
46
|
+
}
|
|
47
|
+
export declare const PLACE_REVALIDATE_SECONDS = 600;
|
|
48
|
+
export declare function createPlaceSource<L extends string, P extends string>(site: Site<L, P>, options: PlaceSourceOptions): PlaceSource<L>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { mergeLive, parsePlaceLive } from "../core/place/live.js";
|
|
3
|
+
export class PlaceSourceError extends Error {
|
|
4
|
+
name = "PlaceSourceError";
|
|
5
|
+
}
|
|
6
|
+
export const PLACE_REVALIDATE_SECONDS = 600;
|
|
7
|
+
const TIMEOUT_MS = 3_000;
|
|
8
|
+
export function createPlaceSource(site, options) {
|
|
9
|
+
const revalidate = options.revalidateSeconds ?? PLACE_REVALIDATE_SECONDS;
|
|
10
|
+
const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
|
|
11
|
+
const log = options.log ?? console;
|
|
12
|
+
async function fetchOne(base, baked, locale) {
|
|
13
|
+
const init = { cache: "force-cache", next: { revalidate }, signal: AbortSignal.timeout(timeoutMs) };
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = await fetch(`${base}/locations/${encodeURIComponent(baked.slug)}?locale=${locale}`, init);
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
return { kind: "unreachable", cause };
|
|
20
|
+
}
|
|
21
|
+
// Withdrawn is the source's own word: a 410, or a 404 it answered in its
|
|
22
|
+
// JSON. A bare 404 is as likely an ingress with no route yet — a failing
|
|
23
|
+
// source, which keeps the baked place.
|
|
24
|
+
if (response.status === 410)
|
|
25
|
+
return { kind: "missing" };
|
|
26
|
+
if (response.status === 404) {
|
|
27
|
+
return response.headers.get("content-type")?.includes("application/json") ? { kind: "missing" } : { kind: "failed", status: 404 };
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
return { kind: "failed", status: response.status };
|
|
31
|
+
try {
|
|
32
|
+
return { kind: "live", place: mergeLive(baked, parsePlaceLive(await response.json(), site.i18n.locales)) };
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
// A body that is not the source's shape is the source failing, not the place.
|
|
36
|
+
return { kind: "unreachable", cause };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const baked = (slug) => site.places.find(p => p.slug === slug);
|
|
40
|
+
return {
|
|
41
|
+
async getPlace(slug, locale) {
|
|
42
|
+
const place = baked(slug);
|
|
43
|
+
if (!place)
|
|
44
|
+
return null;
|
|
45
|
+
const base = options.baseUrl();
|
|
46
|
+
if (!base)
|
|
47
|
+
return place;
|
|
48
|
+
const fetched = await fetchOne(base, place, locale);
|
|
49
|
+
switch (fetched.kind) {
|
|
50
|
+
case "live":
|
|
51
|
+
return fetched.place;
|
|
52
|
+
case "missing":
|
|
53
|
+
return null;
|
|
54
|
+
// The gate fields may be missing from the baked place — then the page
|
|
55
|
+
// is served `noindex` until the source is back, never a 404 or a 500.
|
|
56
|
+
case "unreachable":
|
|
57
|
+
log.error(`live place ${slug}: source unreachable, serving baked`, fetched.cause);
|
|
58
|
+
return place;
|
|
59
|
+
case "failed":
|
|
60
|
+
log.error(`live place ${slug}: source answered ${fetched.status}, serving baked`);
|
|
61
|
+
return place;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
async getPlaceStrict(slug, locale) {
|
|
65
|
+
const place = baked(slug);
|
|
66
|
+
if (!place)
|
|
67
|
+
return null;
|
|
68
|
+
const base = options.baseUrl();
|
|
69
|
+
if (!base)
|
|
70
|
+
return place;
|
|
71
|
+
const fetched = await fetchOne(base, place, locale);
|
|
72
|
+
switch (fetched.kind) {
|
|
73
|
+
case "live":
|
|
74
|
+
return fetched.place;
|
|
75
|
+
case "missing":
|
|
76
|
+
return null;
|
|
77
|
+
case "unreachable":
|
|
78
|
+
throw new PlaceSourceError(`place ${slug} unreachable`, { cause: fetched.cause });
|
|
79
|
+
case "failed":
|
|
80
|
+
throw new PlaceSourceError(`place ${slug}: source answered ${fetched.status}`);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async listPlaces(locale, mode) {
|
|
84
|
+
const base = options.baseUrl();
|
|
85
|
+
if (!base)
|
|
86
|
+
return [...site.places];
|
|
87
|
+
const results = await Promise.all(site.places.map(async (place) => {
|
|
88
|
+
const fetched = await fetchOne(base, place, locale);
|
|
89
|
+
switch (fetched.kind) {
|
|
90
|
+
case "live":
|
|
91
|
+
return fetched.place;
|
|
92
|
+
case "missing":
|
|
93
|
+
return null;
|
|
94
|
+
case "unreachable":
|
|
95
|
+
if (mode === "sitemap")
|
|
96
|
+
throw new PlaceSourceError(`place list: ${place.slug} unreachable`, { cause: fetched.cause });
|
|
97
|
+
return place;
|
|
98
|
+
case "failed":
|
|
99
|
+
if (mode === "sitemap")
|
|
100
|
+
throw new PlaceSourceError(`place list: ${place.slug} answered ${fetched.status}`);
|
|
101
|
+
return place;
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
return results.filter((p) => p !== null);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
/**
|
|
3
|
+
* A minimal SMTP submission client: one message, plain text, AUTH PLAIN,
|
|
4
|
+
* STARTTLS when offered. Enough to tell a plumber a lead arrived, without a
|
|
5
|
+
* mail library in the dependency tree.
|
|
6
|
+
*
|
|
7
|
+
* `smtps://user:pass@host:465` is implicit TLS; `smtp://…:587` upgrades with
|
|
8
|
+
* STARTTLS, and refuses to send anything when the server offers no TLS —
|
|
9
|
+
* unless the host is loopback, a local mail catcher.
|
|
10
|
+
*/
|
|
11
|
+
export interface Mail {
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Sends one message or rejects. Every way the conversation can die rejects
|
|
19
|
+
* rather than hangs: an error or a close on either socket (the raw one, and
|
|
20
|
+
* the TLS one over it after STARTTLS), and one deadline over the whole
|
|
21
|
+
* exchange — a server that accepts the connection and then says nothing must
|
|
22
|
+
* not hold a request's `after()` task forever.
|
|
23
|
+
*
|
|
24
|
+
* Off loopback, TLS is required: implicit (`smtps://`) or STARTTLS. A server
|
|
25
|
+
* that offers neither gets nothing — not the credentials, and not the
|
|
26
|
+
* customer's phone number in the body. The lead is already stored, so a
|
|
27
|
+
* refusal here is loud in the log and costs nothing else.
|
|
28
|
+
*
|
|
29
|
+
* `helo` is the name this client greets a remote relay with — the site's own
|
|
30
|
+
* domain; a loopback catcher is greeted as `localhost`.
|
|
31
|
+
*/
|
|
32
|
+
/** `SMTP_URL`, checked; the error never repeats it, since it carries the password. */
|
|
33
|
+
export declare function parseSmtpUrl(value: string | URL): URL;
|
|
34
|
+
export interface SendOptions {
|
|
35
|
+
helo: string;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function sendMail(smtpUrl: string | URL, mail: Mail, options: SendOptions): Promise<void>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { connect as tcp } from "node:net";
|
|
3
|
+
import { connect as tlsConnect } from "node:tls";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
/** The whole conversation, connect to QUIT. */
|
|
6
|
+
const TOTAL_TIMEOUT_MS = 20_000;
|
|
7
|
+
class Replies {
|
|
8
|
+
socket;
|
|
9
|
+
buffer = "";
|
|
10
|
+
waiting = [];
|
|
11
|
+
queued = [];
|
|
12
|
+
onData = (chunk) => this.feed(chunk.toString("utf8"));
|
|
13
|
+
constructor(socket) {
|
|
14
|
+
this.socket = socket;
|
|
15
|
+
socket.on("data", this.onData);
|
|
16
|
+
}
|
|
17
|
+
feed(data) {
|
|
18
|
+
this.buffer += data;
|
|
19
|
+
let end;
|
|
20
|
+
let lines = [];
|
|
21
|
+
while ((end = this.buffer.indexOf("\r\n")) >= 0) {
|
|
22
|
+
const line = this.buffer.slice(0, end);
|
|
23
|
+
this.buffer = this.buffer.slice(end + 2);
|
|
24
|
+
lines.push(line);
|
|
25
|
+
// `250-…` continues a reply, `250 …` ends it.
|
|
26
|
+
if (/^\d{3}(?: |$)/.test(line)) {
|
|
27
|
+
const reply = { code: Number(line.slice(0, 3)), text: lines.join("\n") };
|
|
28
|
+
lines = [];
|
|
29
|
+
const next = this.waiting.shift();
|
|
30
|
+
if (next)
|
|
31
|
+
next(reply);
|
|
32
|
+
else
|
|
33
|
+
this.queued.push(reply);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (lines.length)
|
|
37
|
+
this.buffer = `${lines.join("\r\n")}\r\n${this.buffer}`;
|
|
38
|
+
}
|
|
39
|
+
next() {
|
|
40
|
+
const ready = this.queued.shift();
|
|
41
|
+
if (ready)
|
|
42
|
+
return Promise.resolve(ready);
|
|
43
|
+
return new Promise(resolve => this.waiting.push(resolve));
|
|
44
|
+
}
|
|
45
|
+
/** Before STARTTLS: the raw socket is about to carry ciphertext. */
|
|
46
|
+
detach() {
|
|
47
|
+
this.socket.off("data", this.onData);
|
|
48
|
+
this.buffer = "";
|
|
49
|
+
this.queued = [];
|
|
50
|
+
}
|
|
51
|
+
attach(socket) {
|
|
52
|
+
this.socket = socket;
|
|
53
|
+
socket.on("data", this.onData);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function encodeHeader(value) {
|
|
57
|
+
// RFC 2047: a subject with an accent or a place name is not 7-bit.
|
|
58
|
+
return /^[\x20-\x7e]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
|
|
59
|
+
}
|
|
60
|
+
async function open(url, watch) {
|
|
61
|
+
const implicit = url.protocol === "smtps:";
|
|
62
|
+
const port = Number(url.port || (implicit ? 465 : 587));
|
|
63
|
+
const host = hostOf(url);
|
|
64
|
+
const socket = implicit ? tlsConnect({ host, port, servername: host }) : tcp({ host, port });
|
|
65
|
+
watch(socket);
|
|
66
|
+
await new Promise(resolve => socket.once(implicit ? "secureConnect" : "connect", () => resolve()));
|
|
67
|
+
return socket;
|
|
68
|
+
}
|
|
69
|
+
function message(mail) {
|
|
70
|
+
const body = Buffer.from(mail.text, "utf8").toString("base64").replace(/.{76}/g, "$&\r\n");
|
|
71
|
+
const domain = mail.from.split("@")[1] ?? "localhost";
|
|
72
|
+
return [
|
|
73
|
+
`From: ${mail.from}`,
|
|
74
|
+
`To: ${mail.to}`,
|
|
75
|
+
`Subject: ${encodeHeader(mail.subject)}`,
|
|
76
|
+
`Date: ${new Date().toUTCString()}`,
|
|
77
|
+
// Without one, some relays add their own and some spam filters score it.
|
|
78
|
+
`Message-ID: <${randomUUID()}@${domain}>`,
|
|
79
|
+
"MIME-Version: 1.0",
|
|
80
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
81
|
+
"Content-Transfer-Encoding: base64",
|
|
82
|
+
"",
|
|
83
|
+
body,
|
|
84
|
+
].join("\r\n");
|
|
85
|
+
}
|
|
86
|
+
const LOOPBACK = ["localhost", "127.0.0.1", "::1"];
|
|
87
|
+
/** `URL.hostname` keeps an IPv6 literal's brackets; sockets want it bare. */
|
|
88
|
+
const hostOf = (url) => url.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
89
|
+
/**
|
|
90
|
+
* Sends one message or rejects. Every way the conversation can die rejects
|
|
91
|
+
* rather than hangs: an error or a close on either socket (the raw one, and
|
|
92
|
+
* the TLS one over it after STARTTLS), and one deadline over the whole
|
|
93
|
+
* exchange — a server that accepts the connection and then says nothing must
|
|
94
|
+
* not hold a request's `after()` task forever.
|
|
95
|
+
*
|
|
96
|
+
* Off loopback, TLS is required: implicit (`smtps://`) or STARTTLS. A server
|
|
97
|
+
* that offers neither gets nothing — not the credentials, and not the
|
|
98
|
+
* customer's phone number in the body. The lead is already stored, so a
|
|
99
|
+
* refusal here is loud in the log and costs nothing else.
|
|
100
|
+
*
|
|
101
|
+
* `helo` is the name this client greets a remote relay with — the site's own
|
|
102
|
+
* domain; a loopback catcher is greeted as `localhost`.
|
|
103
|
+
*/
|
|
104
|
+
/** `SMTP_URL`, checked; the error never repeats it, since it carries the password. */
|
|
105
|
+
export function parseSmtpUrl(value) {
|
|
106
|
+
let url;
|
|
107
|
+
try {
|
|
108
|
+
url = value instanceof URL ? value : new URL(value);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new Error("SMTP_URL is not a URL");
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== "smtp:" && url.protocol !== "smtps:")
|
|
114
|
+
throw new Error(`SMTP_URL: unsupported scheme ${url.protocol}`);
|
|
115
|
+
if (!url.hostname)
|
|
116
|
+
throw new Error("SMTP_URL: no host");
|
|
117
|
+
return url;
|
|
118
|
+
}
|
|
119
|
+
export async function sendMail(smtpUrl, mail, options) {
|
|
120
|
+
const timeoutMs = options.timeoutMs ?? TOTAL_TIMEOUT_MS;
|
|
121
|
+
const url = parseSmtpUrl(smtpUrl);
|
|
122
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
123
|
+
let fail = () => undefined;
|
|
124
|
+
const failed = new Promise((_, reject) => (fail = reject));
|
|
125
|
+
// Handled here so a failure after the last exchange is not an unhandled
|
|
126
|
+
// rejection; every step still races against it.
|
|
127
|
+
failed.catch(() => undefined);
|
|
128
|
+
const sockets = [];
|
|
129
|
+
const watch = (s) => {
|
|
130
|
+
sockets.push(s);
|
|
131
|
+
s.on("error", fail);
|
|
132
|
+
s.once("close", () => fail(new Error("smtp: connection closed")));
|
|
133
|
+
};
|
|
134
|
+
deadline.addEventListener("abort", () => {
|
|
135
|
+
fail(new Error(`smtp: no answer within ${timeoutMs} ms`));
|
|
136
|
+
for (const s of sockets)
|
|
137
|
+
s.destroy();
|
|
138
|
+
});
|
|
139
|
+
let socket = await Promise.race([open(url, watch), failed]);
|
|
140
|
+
const replies = new Replies(socket);
|
|
141
|
+
const step = (p) => Promise.race([p, failed]);
|
|
142
|
+
const expect = async (ok, what) => {
|
|
143
|
+
const reply = await step(replies.next());
|
|
144
|
+
if (!ok(reply.code))
|
|
145
|
+
throw new Error(`smtp: ${what} refused: ${reply.text}`);
|
|
146
|
+
return reply;
|
|
147
|
+
};
|
|
148
|
+
const say = (line) => socket.write(`${line}\r\n`);
|
|
149
|
+
const hello = url.hostname.includes(".") ? options.helo : "localhost";
|
|
150
|
+
const loopback = LOOPBACK.includes(hostOf(url));
|
|
151
|
+
let done = false;
|
|
152
|
+
try {
|
|
153
|
+
await expect(c => c === 220, "greeting");
|
|
154
|
+
say(`EHLO ${hello}`);
|
|
155
|
+
const caps = await expect(c => c === 250, "EHLO");
|
|
156
|
+
let encrypted = url.protocol === "smtps:";
|
|
157
|
+
if (!encrypted && /STARTTLS/i.test(caps.text)) {
|
|
158
|
+
say("STARTTLS");
|
|
159
|
+
await expect(c => c === 220, "STARTTLS");
|
|
160
|
+
replies.detach();
|
|
161
|
+
const raw = socket;
|
|
162
|
+
socket = tlsConnect({ socket: raw, servername: hostOf(url) });
|
|
163
|
+
watch(socket);
|
|
164
|
+
await step(new Promise(resolve => socket.once("secureConnect", () => resolve())));
|
|
165
|
+
encrypted = true;
|
|
166
|
+
replies.attach(socket);
|
|
167
|
+
say(`EHLO ${hello}`);
|
|
168
|
+
await expect(c => c === 250, "EHLO after STARTTLS");
|
|
169
|
+
}
|
|
170
|
+
if (!encrypted && !loopback)
|
|
171
|
+
throw new Error("smtp: the server offers no TLS; refusing to send a lead in the clear");
|
|
172
|
+
if (url.username) {
|
|
173
|
+
const plain = Buffer.from(`\0${decodeURIComponent(url.username)}\0${decodeURIComponent(url.password)}`).toString("base64");
|
|
174
|
+
say(`AUTH PLAIN ${plain}`);
|
|
175
|
+
await expect(c => c === 235, "AUTH");
|
|
176
|
+
}
|
|
177
|
+
say(`MAIL FROM:<${mail.from}>`);
|
|
178
|
+
await expect(c => c === 250, "MAIL FROM");
|
|
179
|
+
say(`RCPT TO:<${mail.to}>`);
|
|
180
|
+
await expect(c => c === 250 || c === 251, "RCPT TO");
|
|
181
|
+
say("DATA");
|
|
182
|
+
await expect(c => c === 354, "DATA");
|
|
183
|
+
// Dot-stuffing: a line that starts with "." would otherwise end the data.
|
|
184
|
+
say(`${message(mail).replace(/\r\n\./g, "\r\n..")}\r\n.`);
|
|
185
|
+
await expect(c => c === 250, "message");
|
|
186
|
+
done = true;
|
|
187
|
+
say("QUIT");
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
// After the message is accepted, the server closing on QUIT is expected.
|
|
191
|
+
if (!done)
|
|
192
|
+
for (const s of sockets)
|
|
193
|
+
s.destroy();
|
|
194
|
+
else
|
|
195
|
+
socket.end();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Expect, Page, TestType } from "@playwright/test";
|
|
2
|
+
/**
|
|
3
|
+
* `@evinvest/kitstart/testing/e2e` — the section-baseline runner a brand's
|
|
4
|
+
* Playwright suite calls. One baseline per section at both designed
|
|
5
|
+
* breakpoints; every section reached by its own URL (a `#` the site itself
|
|
6
|
+
* links to), never by scrolling to it — a scroll is a moving target the runner
|
|
7
|
+
* would have to guess is over.
|
|
8
|
+
*
|
|
9
|
+
* Types only from `@playwright/test`: the brand passes its own `test` and
|
|
10
|
+
* `expect`, so this module loads nothing at run time and cannot end up with a
|
|
11
|
+
* second copy of the runner (Playwright refuses to run with two).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Waits until a section is what a visitor sees: web fonts applied, every
|
|
15
|
+
* image in it loaded (lazy ones forced), the network idle, and every finite
|
|
16
|
+
* animation finished — `animations: "disabled"` freezes CSS animations, not
|
|
17
|
+
* ones driven through the Web Animations API.
|
|
18
|
+
*/
|
|
19
|
+
export declare function settle(page: Page, selector: string): Promise<void>;
|
|
20
|
+
export interface Section {
|
|
21
|
+
name: string;
|
|
22
|
+
/** Relative to the config's `baseURL`, or absolute for another host. */
|
|
23
|
+
url: string;
|
|
24
|
+
selector: string;
|
|
25
|
+
/** `md:hidden` in the design: shot only in the `mobile` project. */
|
|
26
|
+
mobileOnly?: boolean;
|
|
27
|
+
/** Shot without the section stylesheet (the call bar hides itself in it). */
|
|
28
|
+
bare?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** The two designed breakpoints, as Playwright projects. */
|
|
31
|
+
export declare const BREAKPOINTS: readonly [{
|
|
32
|
+
readonly name: "desktop";
|
|
33
|
+
readonly viewport: {
|
|
34
|
+
readonly width: 1440;
|
|
35
|
+
readonly height: 900;
|
|
36
|
+
};
|
|
37
|
+
}, {
|
|
38
|
+
readonly name: "mobile";
|
|
39
|
+
readonly viewport: {
|
|
40
|
+
readonly width: 390;
|
|
41
|
+
readonly height: 844;
|
|
42
|
+
};
|
|
43
|
+
}];
|
|
44
|
+
type AnyTest = TestType<any, any>;
|
|
45
|
+
/** The brand's own runner: `import { test, expect } from "@playwright/test"`. */
|
|
46
|
+
export interface Runner {
|
|
47
|
+
test: AnyTest;
|
|
48
|
+
expect: Expect;
|
|
49
|
+
}
|
|
50
|
+
/** One `test` per section, each a `toHaveScreenshot` of the settled section. */
|
|
51
|
+
export declare function defineSectionSuite({ test, expect }: Runner, sections: readonly Section[]): void;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/testing/e2e` — the section-baseline runner a brand's
|
|
3
|
+
* Playwright suite calls. One baseline per section at both designed
|
|
4
|
+
* breakpoints; every section reached by its own URL (a `#` the site itself
|
|
5
|
+
* links to), never by scrolling to it — a scroll is a moving target the runner
|
|
6
|
+
* would have to guess is over.
|
|
7
|
+
*
|
|
8
|
+
* Types only from `@playwright/test`: the brand passes its own `test` and
|
|
9
|
+
* `expect`, so this module loads nothing at run time and cannot end up with a
|
|
10
|
+
* second copy of the runner (Playwright refuses to run with two).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Waits until a section is what a visitor sees: web fonts applied, every
|
|
14
|
+
* image in it loaded (lazy ones forced), the network idle, and every finite
|
|
15
|
+
* animation finished — `animations: "disabled"` freezes CSS animations, not
|
|
16
|
+
* ones driven through the Web Animations API.
|
|
17
|
+
*/
|
|
18
|
+
export async function settle(page, selector) {
|
|
19
|
+
await page.evaluate(() => document.fonts.ready);
|
|
20
|
+
await page.locator(selector).evaluate(root => Promise.all(Array.from(root.querySelectorAll("img"), img => {
|
|
21
|
+
img.loading = "eager";
|
|
22
|
+
if (img.complete)
|
|
23
|
+
return null;
|
|
24
|
+
return new Promise(done => {
|
|
25
|
+
img.addEventListener("load", done, { once: true });
|
|
26
|
+
img.addEventListener("error", done, { once: true });
|
|
27
|
+
});
|
|
28
|
+
})));
|
|
29
|
+
await page.waitForLoadState("networkidle");
|
|
30
|
+
await page.waitForFunction(() => document
|
|
31
|
+
.getAnimations()
|
|
32
|
+
.filter(a => (a.effect?.getComputedTiming().iterations ?? 1) !== Infinity)
|
|
33
|
+
.every(a => a.playState === "finished" || a.playState === "idle"), undefined, { timeout: 10_000 });
|
|
34
|
+
}
|
|
35
|
+
/** The two designed breakpoints, as Playwright projects. */
|
|
36
|
+
export const BREAKPOINTS = [
|
|
37
|
+
{ name: "desktop", viewport: { width: 1440, height: 900 } },
|
|
38
|
+
{ name: "mobile", viewport: { width: 390, height: 844 } },
|
|
39
|
+
];
|
|
40
|
+
/** One `test` per section, each a `toHaveScreenshot` of the settled section. */
|
|
41
|
+
export function defineSectionSuite({ test, expect }, sections) {
|
|
42
|
+
// The site scrolls smoothly for everyone who has not asked it not to;
|
|
43
|
+
// `use.reducedMotion` does not reach the page, `emulateMedia` does.
|
|
44
|
+
test.beforeEach(async ({ page }) => {
|
|
45
|
+
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
46
|
+
});
|
|
47
|
+
for (const section of sections) {
|
|
48
|
+
test(`section: ${section.name}`, async ({ page }, info) => {
|
|
49
|
+
test.skip(Boolean(section.mobileOnly) && info.project.name !== "mobile");
|
|
50
|
+
await page.goto(section.url);
|
|
51
|
+
const locator = page.locator(section.selector);
|
|
52
|
+
await expect(locator).toBeVisible();
|
|
53
|
+
await settle(page, section.selector);
|
|
54
|
+
await expect(locator).toHaveScreenshot(`${section.name}-${info.project.name}.png`, section.bare ? { stylePath: [] } : {});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Lead } from "../core/lead.js";
|
|
2
|
+
import type { Place } from "../core/place/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Places and leads for a brand's tests, in the two shapes the model has. Each
|
|
5
|
+
* takes overrides, so a test names only what it is about.
|
|
6
|
+
*/
|
|
7
|
+
export declare function storefrontPlace<L extends string>(locales: readonly L[], over?: Partial<Place<L>> & {
|
|
8
|
+
slug?: string;
|
|
9
|
+
}): Place<L>;
|
|
10
|
+
export declare function serviceAreaPlace<L extends string>(locales: readonly L[], over?: Partial<Place<L>>): Place<L>;
|
|
11
|
+
export declare function testLead(over?: Partial<Lead>): Lead;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One string in every locale. */
|
|
2
|
+
function sameIn(locales, text) {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const l of locales)
|
|
5
|
+
out[l] = text;
|
|
6
|
+
if (!complete(out, locales))
|
|
7
|
+
throw new Error("sameIn: a locale was skipped");
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
function complete(record, locales) {
|
|
11
|
+
return locales.every(l => typeof record[l] === "string");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Places and leads for a brand's tests, in the two shapes the model has. Each
|
|
15
|
+
* takes overrides, so a test names only what it is about.
|
|
16
|
+
*/
|
|
17
|
+
export function storefrontPlace(locales, over = {}) {
|
|
18
|
+
const name = sameIn(locales, "Royat");
|
|
19
|
+
return {
|
|
20
|
+
slug: "royat",
|
|
21
|
+
gbpName: "Test Storefront — Royat",
|
|
22
|
+
name,
|
|
23
|
+
presence: {
|
|
24
|
+
kind: "storefront",
|
|
25
|
+
address: { street: "2 Av. Abbé Védrine", postalCode: "63130", locality: "Royat", region: "Auvergne-Rhône-Alpes", country: "FR" },
|
|
26
|
+
geo: null,
|
|
27
|
+
storefrontPhoto: null,
|
|
28
|
+
landmark: null,
|
|
29
|
+
},
|
|
30
|
+
serviceArea: null,
|
|
31
|
+
channels: { phone: null, whatsapp: null },
|
|
32
|
+
hours: null,
|
|
33
|
+
rating: null,
|
|
34
|
+
...over,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function serviceAreaPlace(locales, over = {}) {
|
|
38
|
+
const name = sameIn(locales, "Paris");
|
|
39
|
+
return {
|
|
40
|
+
slug: "paris",
|
|
41
|
+
gbpName: "Test Service — Paris",
|
|
42
|
+
name,
|
|
43
|
+
presence: { kind: "service-area" },
|
|
44
|
+
serviceArea: [{ kind: "localities", names: ["Paris", "Boulogne-Billancourt"] }],
|
|
45
|
+
channels: { phone: null, whatsapp: null },
|
|
46
|
+
hours: [{ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], opens: "08:00", closes: "19:00" }],
|
|
47
|
+
rating: null,
|
|
48
|
+
...over,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function testLead(over = {}) {
|
|
52
|
+
return { subject: "other", locality: "63130", mobile: "06 12 34 56 78", extras: {}, placeSlug: null, spamVerdict: null, ...over };
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/testing` — the suites a brand runs from its own vitest:
|
|
3
|
+
* the landing contract over its site config, the lead store contract over any
|
|
4
|
+
* adapter, and fixtures in both place shapes. `vitest` is not a peer: the
|
|
5
|
+
* brand installs it in its own `devDependencies`, like `@playwright/test` for
|
|
6
|
+
* `./testing/e2e`.
|
|
7
|
+
* The Playwright half is `./testing/e2e`.
|
|
8
|
+
*/
|
|
9
|
+
export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
|
|
10
|
+
export { servedPaths } from "./served-files.js";
|
|
11
|
+
export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
|
|
12
|
+
export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/testing` — the suites a brand runs from its own vitest:
|
|
3
|
+
* the landing contract over its site config, the lead store contract over any
|
|
4
|
+
* adapter, and fixtures in both place shapes. `vitest` is not a peer: the
|
|
5
|
+
* brand installs it in its own `devDependencies`, like `@playwright/test` for
|
|
6
|
+
* `./testing/e2e`.
|
|
7
|
+
* The Playwright half is `./testing/e2e`.
|
|
8
|
+
*/
|
|
9
|
+
export { describeLandingContract } from "./landing-contract.js";
|
|
10
|
+
export { servedPaths } from "./served-files.js";
|
|
11
|
+
export { describeLeadStoreContract } from "./lead-store-contract.js";
|
|
12
|
+
export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
|