zz-meridian 0.16.2 → 0.16.3
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/dist/migrations.js +24 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +16 -0
- package/payload/app/(dashboard)/members/actions.ts +2 -1
- package/payload/app/api/assistant/route.ts +5 -9
- package/payload/docs/agents.md +1 -1
- package/payload/next.config.ts +3 -0
- package/payload/package.json +1 -1
- package/payload/scripts/ask.ts +7 -2
- package/payload/scripts/gate.ts +29 -10
- package/payload/scripts/lib/journey.ts +467 -0
- package/payload/scripts/navigate.ts +2 -458
- package/payload/scripts/perf.ts +3 -2
- package/payload/scripts/verify.ts +4 -2
- package/payload/skills/zz-meridian/references/agents.md +1 -1
- package/payload/src/data/metrics.ts +5 -3
- package/payload/src/lib/format-date.ts +16 -0
- package/payload/src/lib/shared-context.ts +39 -1
- package/payload/src/views/customer-context.ts +2 -2
- package/payload/src/views/customer.tsx +2 -2
- package/payload/src/views/members.tsx +2 -2
- package/payload/src/views/now-parts.ts +5 -5
- package/payload/src/views/revenue-parts.ts +2 -2
- package/payload/tests/navigate-menu-start.test.ts +1 -1
- package/payload/tests/navigate-start.test.ts +1 -1
- package/payload/tests/view-page-match.test.ts +56 -0
package/dist/migrations.js
CHANGED
|
@@ -430,6 +430,30 @@ export const RELEASE_MIGRATIONS = [
|
|
|
430
430
|
instructions: 'Bring `tests/rail-marker.test.tsx` over from the release as it has it. Since 0.16.2 `src/components/patterns/rail/index.tsx` reads no offset of its own — a rail mounts inside the tap that opens the phone drawer, and a read there lays the page out inside that tap — so the marker\'s first position is the one `observe` delivers, after the effect that called it. A test whose fake answers `observe` with an empty body expects the marker before any delivery and fails; the release\'s fake answers it with the callback, `observe() { for (const cb of observers) queueMicrotask(cb); }`, and reads the marker after that delivery. A test of your own that mounts the rail changes the same way.',
|
|
431
431
|
checks: ['gate'],
|
|
432
432
|
},
|
|
433
|
+
{
|
|
434
|
+
id: 'view-page-matched-as-next',
|
|
435
|
+
since: '0.16.3',
|
|
436
|
+
applies: (root) => (/\bfunction onPage\s*\(/.test(text(root, 'app/api/assistant/route.ts') ?? '') ? ['app/api/assistant/route.ts'] : null),
|
|
437
|
+
summary: 'The assistant route finds the view of the page a person is on with `viewAt` from `src/lib/shared-context.ts`, which matches a catch-all `page` as Next.js does.',
|
|
438
|
+
instructions: 'In `app/api/assistant/route.ts`, delete the local `onPage` and find the view with `viewAt` from `@/lib/shared-context`: `const at = viewAt(viewTools, path); const view = at && views.includes(at) ? at : undefined;`, where `views` are the view tools the person may use. `onPage` matched a `[...name]` segment as one segment and a `[[...name]]` never, so a view whose `page` is a catch-all (`/docs/[...path]`) was handed its prepared query at one depth only. `viewAt` matches as Next.js does — `[...name]` one or more segments, `[[...name]]` zero or more — and where two views match one address it takes the one Next.js renders, before asking whether the person may use it, so a less specific view never stands in for the page.',
|
|
439
|
+
checks: ['gate'],
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: 'journey-moved',
|
|
443
|
+
since: '0.16.3',
|
|
444
|
+
applies: (root) => matching(root, (rel, src) => importsOf(rel, src).some(({ from, names, namespace }) => from === 'scripts/navigate' && (namespace !== null || names.some((n) => n !== 'workspaceHomes')))),
|
|
445
|
+
summary: 'The navigation journey moved from `scripts/navigate.ts` to `scripts/lib/journey.ts`, which keeps every source file Meridian ships under 700 lines.',
|
|
446
|
+
instructions: 'Bring `tests/navigate-start.test.ts` and `tests/navigate-menu-start.test.ts` over from the release as it has them. A file of your own that imports the journey — `startFor`, `journeyStart`, `journey`, `drawerRoutes`, `prepare`, `liveRefresh`, `Fail` and the types `Device`, `Metric`, `Condition`, `Measured`, `Sample` and `NavConfig` — imports it from `scripts/lib/journey.ts` instead; `scripts/navigate.ts` keeps the command and `workspaceHomes` only.',
|
|
447
|
+
checks: ['gate'],
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
id: 'today-in-timezone',
|
|
451
|
+
since: '0.16.3',
|
|
452
|
+
applies: (root) => matching(root, (rel, src) => (rel === 'app/api/assistant/route.ts' || rel === 'src/data/metrics.ts') && /\bconst today = clock\(\)\.toISOString\(\)\.slice\(0, 10\)|\btoday: clock\(\)\.toISOString\(\)\.slice\(0, 10\)/.test(src)),
|
|
453
|
+
summary: "Today is the day in `app.timezone` wherever a day is cut: `dayOf(clock())` from `src/lib/format-date.ts`, not the UTC date.",
|
|
454
|
+
instructions: "Replace `clock().toISOString().slice(0, 10)` with `dayOf(clock())` (`dayOf` in `src/lib/format-date.ts`) in each file listed: the prepared query's `today` in `app/api/assistant/route.ts`, and `readDays`'s today in `src/data/metrics.ts`. The prompt has told the model today in `app.timezone` all along, while the prepared query and the page cut their periods at the UTC date, so east of UTC after local midnight (west of UTC in the evening) the model's today and its `7d` window's end were a day apart. A day row is kept under its day in `app.timezone`, so a file of your own that compares an instant's day with one — `now.slice(0, 10)`, `at.slice(0, 10)` — asks `dayOf` too. A product whose `app.timezone` is UTC sees no change. A project that kept the sample's views brings `src/views/now-parts.ts`, `src/views/revenue-parts.ts`, `src/views/customer-context.ts`, `src/views/customer.tsx` and `src/views/members.tsx` over from the release.",
|
|
455
|
+
checks: ['gate'],
|
|
456
|
+
},
|
|
433
457
|
];
|
|
434
458
|
/**
|
|
435
459
|
* A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.16.3] · 2026-10-17
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **The navigation journey moved to `scripts/lib/journey.ts`.** A created project's `tests/navigate-start.test.ts` and `tests/navigate-menu-start.test.ts` import `startFor` and `journeyStart` from `scripts/navigate.ts`, which no longer has them, so they fail the type check until they are brought over. The files are the project's own, so the update does not write them: it reports them as the migration `journey-moved` — bring the release's copies over, and import the journey (`journey`, `journeyStart`, `startFor`, `drawerRoutes`, `prepare`, `liveRefresh`, `Fail` and its types) from `scripts/lib/journey.ts` in a file of your own. `scripts/navigate.ts` keeps the command and `workspaceHomes`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **The default verify does less twice, and the gate runs its three long checks side by side.** `scripts/gate.ts` runs the type check, the lint and the tests together once the quick checks before them pass, and prints their lines in the same order as before. All three run to the end, so one failure never hides another. The build inside `pnpm verify` no longer type-checks the tree a second time, because the gate has just checked it with the same tsconfig: verify sets `MERIDIAN_TYPES_CHECKED` for that build, and the template's `next.config.ts` reads it as `typescript.ignoreBuildErrors`. Every other `next build` still checks types. An adopted project keeps its own `next.config`, so its verify checks types twice as before, unless it adds that line. Measured from a clean tree on one machine: the gate 22.9 → 15.4 s, the build 14.5 → 9.5 s, the default verify 62.2 → 49.8 s. The release's verify had reached 193 s against its 180 s limit on the slowest runner CPU.
|
|
14
|
+
- **Every source file Meridian ships is under 700 lines** ([#41](https://github.com/zhixuan312/zz-meridian/issues/41)). `scripts/navigate.ts` had grown to 775, the only managed file past a ceiling a project may hold its own files to — and a project cannot split a managed file, because the update restores it. The journey and its measurements are `scripts/lib/journey.ts` now (467 lines) and `scripts/navigate.ts` keeps the command and its report (319); nothing either does changed. A test of the package holds every shipped file under `src`, `app`, `scripts` and `tests` to 700 lines, so a release cannot grow one past it again.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Today is the product's day wherever a day is cut** ([#42](https://github.com/zhixuan312/zz-meridian/issues/42)). The assistant's prompt told the model today in `app.timezone`, while the prepared query (`today` in `app/api/assistant/route.ts`) and the pages' periods (`readDays` in `src/data/metrics.ts`) cut their days at the UTC date. So east of UTC after local midnight, and west of it in the evening, the model's today and the end of its `7d` window were a day apart, and a day row — kept under its day in `app.timezone` — fell on the wrong side of the cut. **`dayOf(at)`** (`src/lib/format-date.ts`) is the calendar day of an instant in `app.timezone`, and the route, `readDays` and the sample's views (Now's month and its runs by day, Revenue's ladder and trials, a customer's busiest day and trial days left, a new member's joined date) ask it now. A product whose `app.timezone` is UTC sees no change. The route and `src/data/metrics.ts` are the team's own files, so the update reports them as the migration `today-in-timezone` — replace `clock().toISOString().slice(0, 10)` with `dayOf(clock())`.
|
|
19
|
+
- **A view whose page is a catch-all route is handed its prepared query at every depth** ([#40](https://github.com/zhixuan312/zz-meridian/issues/40)). The assistant route matched a view's `page` against the address segment by segment and required the two to have as many segments, reading `[...name]` as any one segment and `[[...name]]` as one too: a view on `/docs/[...path]` was the page at `/docs/a` and not at `/docs/a/b`, which the same Next.js route renders, and one on `/docs/[[...path]]` was never the page at `/docs`. The match is `viewAt` in `src/lib/shared-context.ts` now, and it matches as Next.js does — `[...name]` one or more segments, `[[...name]]` zero or more — and where two views match one address it takes the one Next.js renders: a static segment over `[name]`, that over a catch-all. The route asks whether the person may use that view after choosing it, so a less specific view never stands in for the page. `app/api/assistant/route.ts` is the team's own file, so the update does not write it: it reports it as the migration `view-page-matched-as-next` — delete the route's `onPage` and find the view with `viewAt`. `node scripts/ask.ts` asks a catch-all page's questions on a link of any depth, and an optional catch-all's on the page with none.
|
|
20
|
+
|
|
5
21
|
## [0.16.2] · 2026-10-16
|
|
6
22
|
|
|
7
23
|
### Breaking
|
|
@@ -4,6 +4,7 @@ import { clock } from '@/data/collections';
|
|
|
4
4
|
import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
|
|
5
5
|
import { assignMemberRole, REFUSALS } from '@/data/member-mutations';
|
|
6
6
|
import type { AnyCollection } from '@/lib/collection';
|
|
7
|
+
import { dayOf } from '@/lib/format-date';
|
|
7
8
|
import type { Result } from '@/views/members';
|
|
8
9
|
import { STATUSES, TEAMS, type Member } from '@/data/sample';
|
|
9
10
|
import { MAIN_ROLES, type AddOn, type MainRole } from '@/data/roles';
|
|
@@ -38,7 +39,7 @@ export async function inviteMember(input: { name: string; email: string; role: M
|
|
|
38
39
|
if (!MAIN_ROLES.includes(input.role) || !TEAMS.includes(input.team)) return { ok: false, error: 'Choose a role and a team from the lists.' };
|
|
39
40
|
// The boundary decides the role the caller may give — an Admin inviting an Owner is refused there with the frozen
|
|
40
41
|
// rule's message — so this path only shapes the invitation.
|
|
41
|
-
return run('create', undefined, (c) => c.create!({ name, email, role: input.role, team: input.team, status: 'Invited', joined: clock()
|
|
42
|
+
return run('create', undefined, (c) => c.create!({ name, email, role: input.role, team: input.team, status: 'Invited', joined: dayOf(clock()), lastActive: null }));
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export async function setMemberStatus(id: string, status: Member['status']): Promise<Result> {
|
|
@@ -12,7 +12,8 @@ import { handedTo } from '@/lib/assistant/scoped';
|
|
|
12
12
|
import type { AgentChange, HandedExplore } from '@/lib/assistant/tools';
|
|
13
13
|
import type { AnyCollection, Grant, Need } from '@/lib/collection';
|
|
14
14
|
import type { PreparedQuery } from '@/lib/explore';
|
|
15
|
-
import
|
|
15
|
+
import { dayOf } from '@/lib/format-date';
|
|
16
|
+
import { viewAt, type ViewTool } from '@/lib/shared-context';
|
|
16
17
|
import { viewTools } from '@/views/tools';
|
|
17
18
|
|
|
18
19
|
/** The panel sends at most the last 100 messages; a larger body than 2 MB is refused before it is parsed. */
|
|
@@ -62,14 +63,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
62
63
|
// they may use it. It reads through `aggregate()` and `read()`, which ask the person's grant again when a call runs;
|
|
63
64
|
// its schema offers only what they may read: the collections handed above, without each one's hidden fields.
|
|
64
65
|
const path = pathOf(String(page?.path ?? ''));
|
|
65
|
-
const
|
|
66
|
+
const at = viewAt(viewTools, path);
|
|
67
|
+
const view = at && views.includes(at) ? at : undefined;
|
|
66
68
|
const names = new Set(readable.map((c) => c.name));
|
|
67
69
|
const reads = new Set(view ? Object.values(view.explore.sources).map((s) => s.collection) : []);
|
|
68
70
|
const explore: HandedExplore | undefined = view && [...reads].some((name) => names.has(name))
|
|
69
71
|
? {
|
|
70
72
|
view,
|
|
71
73
|
rights: { readable: names, hidden: (name) => (readable.find((c) => c.name === name)?.hidden ?? []) as string[] },
|
|
72
|
-
io: { aggregate, read, today: clock()
|
|
74
|
+
io: { aggregate, read, today: dayOf(clock()) },
|
|
73
75
|
page: path,
|
|
74
76
|
}
|
|
75
77
|
: undefined;
|
|
@@ -145,12 +147,6 @@ function pathOf(address: string): string {
|
|
|
145
147
|
return (path.length > 1 ? path.replace(/\/+$/, '') : path) || '/';
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
/** Whether `path` is the page `route` renders: each segment equal, a `[name]` segment matching any one segment. */
|
|
149
|
-
function onPage(route: string, path: string): boolean {
|
|
150
|
-
const want = route.split('/'), got = path.split('/');
|
|
151
|
-
return want.length === got.length && want.every((w, i) => (/^\[[^\]/]+\]$/.test(w) ? got[i] !== '' : w === got[i]));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
150
|
/** The grants a need names: the one, every one of an `allOf`, and none for `public`. */
|
|
155
151
|
const grantsOf = (need: Need): readonly Grant[] => (need === 'public' ? [] : typeof need === 'string' ? [need] : need.allOf);
|
|
156
152
|
|
package/payload/docs/agents.md
CHANGED
|
@@ -83,7 +83,7 @@ The answer is text of at most 3,000 characters, in a fixed order: the window as
|
|
|
83
83
|
|
|
84
84
|
## The console's own assistant
|
|
85
85
|
|
|
86
|
-
The second place is inside the product. The assistant panel reads the page the person is on (its address with the query, the view's shared context, then the visible text), reads further through every `view_<name>` and the page's own prepared query, and proposes changes as Proposals in its thread. It is handed that page's `explore_<view>` and no other page's: the route finds the view whose `page`, the console route of the page it renders, is the address the panel sends, and hands its prepared query only where the person may use that view. There is no tool that reads a collection directly. Its write tools are those of the collections that prepared query reads, among the product's collections (`src/data/collections.ts`), so the members' writes are handed on Members and the key writes on API keys; a change takes the ids the query's drilled rows give, and asked for a change another page makes, the assistant gives that page's address where the person may open it; a page they may not open is never offered as where they make a change. A rail page's `page` is its `href` in `nav`; a detail page's has its segment (`/customers/[id]`) and matches any one segment there. It follows the same rules: it reads what the person may read, changes nothing without approval, and leaves its mark (the Agent mark and the "Assistant" caption). It is off until a model is configured. `docs/assistant.md` says how to switch it on and point it at your data, and how the same tools can later be offered from an MCP server.
|
|
86
|
+
The second place is inside the product. The assistant panel reads the page the person is on (its address with the query, the view's shared context, then the visible text), reads further through every `view_<name>` and the page's own prepared query, and proposes changes as Proposals in its thread. It is handed that page's `explore_<view>` and no other page's: the route finds the view whose `page`, the console route of the page it renders, is the address the panel sends, and hands its prepared query only where the person may use that view. There is no tool that reads a collection directly. Its write tools are those of the collections that prepared query reads, among the product's collections (`src/data/collections.ts`), so the members' writes are handed on Members and the key writes on API keys; a change takes the ids the query's drilled rows give, and asked for a change another page makes, the assistant gives that page's address where the person may open it; a page they may not open is never offered as where they make a change. A rail page's `page` is its `href` in `nav`; a detail page's has its segment (`/customers/[id]`) and matches any one segment there, and a catch-all matches as Next.js matches it: `/docs/[...path]` any depth under `/docs/`, `/docs/[[...path]]` `/docs` as well. Where two views match one address, the one Next.js renders is chosen (`viewAt` in `src/lib/shared-context.ts`). It follows the same rules: it reads what the person may read, changes nothing without approval, and leaves its mark (the Agent mark and the "Assistant" caption). It is off until a model is configured. `docs/assistant.md` says how to switch it on and point it at your data, and how the same tools can later be offered from an MCP server.
|
|
87
87
|
|
|
88
88
|
The page text it reads is the scroll region's visible text, less every node marked `data-assistant="omit"`. A value shown once, such as a newly issued key's secret or a one-time sign-up link, is drawn so marked, or outside the scroll region (a `Dialog` portals to the document's body), so asking while it is on screen never sends it to the model; the sample's API keys page marks a new key's Copy field so. `scripts/ask.ts` leaves out the same nodes from the page it sends. The thread is the signed-in person's for the tenant they act for: it is stored with that scope, a change of either starts an empty thread and removes the earlier one, and each request names the scope, so a session that changed hands since (a sign-in from another tab) is refused with 409 and the panel clears the thread instead of continuing it, then refreshes the route, so the next question is asked as whoever is signed in now. A tab writes and removes only a thread of its own scope.
|
|
89
89
|
|
package/payload/next.config.ts
CHANGED
|
@@ -8,6 +8,9 @@ const config: NextConfig = {
|
|
|
8
8
|
cacheComponents: true,
|
|
9
9
|
partialPrefetching: true,
|
|
10
10
|
devIndicators: false,
|
|
11
|
+
// `pnpm verify` sets MERIDIAN_TYPES_CHECKED for its one build, only after its own gate has type-checked this tree with
|
|
12
|
+
// the same tsconfig, so the build does not check it twice. Every other `next build` checks the types itself.
|
|
13
|
+
typescript: { ignoreBuildErrors: process.env.MERIDIAN_TYPES_CHECKED === '1' },
|
|
11
14
|
// Card specifications (README.md next to each component) are read at build time by the Design Atlas.
|
|
12
15
|
outputFileTracingIncludes: { '/system/**': ['./src/**/*.md', './app/**/*.md', './docs/**/*.md', './decisions/**/*.md', './*.md', './tokens/**/*.json'], '/icon': ['./tokens/**/*.json'], '/api/assistant': ['./docs/brief.md'] },
|
|
13
16
|
};
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
package/payload/scripts/ask.ts
CHANGED
|
@@ -317,11 +317,16 @@ function report(view: View, address: string, persona: string, base: string, page
|
|
|
317
317
|
return `${out.join('\n')}\n`;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
/**
|
|
320
|
+
/**
|
|
321
|
+
* The address a page's questions are asked on: the page itself, or for a detail page the first record its listing page
|
|
322
|
+
* links. An optional catch-all (`/docs/[[...path]]`) renders with none of its segments too, so it is asked there; a
|
|
323
|
+
* catch-all takes a link of any depth.
|
|
324
|
+
*/
|
|
321
325
|
async function addressOf(base: string, cookie: string, page: string): Promise<string | null> {
|
|
322
326
|
if (!/\[[^\]/]+\]/.test(page)) return page;
|
|
327
|
+
if (/\/\[\[\.\.\.[^\]/]+\]\]$/.test(page)) return addressOf(base, cookie, page.replace(/\/\[\[\.\.\.[^\]/]+\]\]$/, '') || '/');
|
|
323
328
|
const listing = page.split('/[')[0] || '/';
|
|
324
|
-
const form = page.split('/').map((s) => (/^\[[^\]/]+\]$/.test(s) ? `[^/"'?#\\s<>]+` : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/');
|
|
329
|
+
const form = page.split('/').map((s) => (/^\[\.\.\.[^\]/]+\]$/.test(s) ? `[^"'?#\\s<>]+` : /^\[[^\]/]+\]$/.test(s) ? `[^/"'?#\\s<>]+` : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/');
|
|
325
330
|
const html = await (await fetch(`${base}${listing}`, { headers: { cookie }, signal: AbortSignal.timeout(60_000) })).text();
|
|
326
331
|
return new RegExp(`href="(${form})(?:[?#][^"]*)?"`).exec(html)?.[1] ?? null;
|
|
327
332
|
}
|
package/payload/scripts/gate.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Every gate the system must pass before a commit, in order; stops at the first failure.
|
|
2
|
+
* Every gate the system must pass before a commit, in order; stops at the first failure. The type check, the lint and
|
|
3
|
+
* the tests read the same tree and write nothing the others read, so they run side by side once the checks before them
|
|
4
|
+
* pass: each runs to its end, and their lines print in the order below.
|
|
3
5
|
*
|
|
4
6
|
* pnpm gate (or npm run gate: the tools run from node_modules/.bin, under any package manager)
|
|
5
7
|
* node scripts/gate.ts --without-cli-tests leaves out the tests only this repository has, neither shipped:
|
|
@@ -7,7 +9,7 @@
|
|
|
7
9
|
* tests of the checks). The default verify runs it this way, and the
|
|
8
10
|
* release runs those tests in a step of their own
|
|
9
11
|
*/
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
13
|
import fs from 'node:fs';
|
|
12
14
|
import path from 'node:path';
|
|
13
15
|
import { bin } from './lib/bin.ts';
|
|
@@ -31,15 +33,32 @@ const STEPS: [string, string[]][] = [
|
|
|
31
33
|
['tests pass', [bin('vitest'), 'run', ...(withoutCliTests ? ['--exclude', 'tests/cli-*', '--exclude', 'tests/deep-*', '--exclude', '**/node_modules/**'] : [])]],
|
|
32
34
|
];
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/** The steps that run side by side, from the first of them to the end. */
|
|
37
|
+
const TOGETHER = STEPS.findIndex(([name]) => name === 'types check');
|
|
38
|
+
|
|
39
|
+
const report = (name: string, t: number, r: { status: number | null; out: string; error?: Error }, cmd: string) => {
|
|
40
|
+
const ok = r.status === 0;
|
|
41
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} (${(t / 1000).toFixed(1)}s)`);
|
|
42
|
+
// A tool that could not start (not installed) has no output, only the error.
|
|
43
|
+
if (!ok) console.log(`${r.out}${r.error ? `${cmd}: ${r.error.message}` : ''}`.trim().split('\n').slice(-40).join('\n'));
|
|
44
|
+
return ok;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (const [name, [cmd, ...args]] of STEPS.slice(0, TOGETHER)) {
|
|
35
48
|
const t = Date.now();
|
|
36
49
|
const r = spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8' });
|
|
37
|
-
|
|
38
|
-
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} (${((Date.now() - t) / 1000).toFixed(1)}s)`);
|
|
39
|
-
if (!ok) {
|
|
40
|
-
// A tool that could not start (not installed) has no output, only the error.
|
|
41
|
-
console.log(`${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? `${cmd}: ${r.error.message}` : ''}`.trim().split('\n').slice(-40).join('\n'));
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
50
|
+
if (!report(name, Date.now() - t, { status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}`, error: r.error }, cmd)) process.exit(1);
|
|
44
51
|
}
|
|
52
|
+
|
|
53
|
+
const together = await Promise.all(STEPS.slice(TOGETHER).map(([name, [cmd, ...args]]) => new Promise<{ name: string; cmd: string; t: number; status: number | null; out: string; error?: Error }>((resolve) => {
|
|
54
|
+
const t = Date.now();
|
|
55
|
+
let out = '';
|
|
56
|
+
const child = spawn(cmd, args, { cwd: ROOT });
|
|
57
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
58
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
59
|
+
child.on('error', (error) => resolve({ name, cmd, t: Date.now() - t, status: null, out, error }));
|
|
60
|
+
child.on('close', (status) => resolve({ name, cmd, t: Date.now() - t, status, out }));
|
|
61
|
+
})));
|
|
62
|
+
// Each line is reported, the failed ones with their output: none is cut short by another's failure.
|
|
63
|
+
if (!together.map((r) => report(r.name, r.t, r, r.cmd)).every(Boolean)) process.exit(1);
|
|
45
64
|
console.log(`gate: every check passed${withoutCliTests ? " (the tests only this repository has left out: tests/cli-*, tests/deep-*)" : ''}`);
|