xblox 1/2
This commit is contained in:
parent
968268d7ca
commit
4c5e7d7690
1
packages/xblox/.gitignore
vendored
1
packages/xblox/.gitignore
vendored
@ -2,4 +2,5 @@ node_modules/
|
||||
dist/
|
||||
dist-in/
|
||||
coverage/
|
||||
reports/
|
||||
*.log
|
||||
|
||||
@ -34,7 +34,7 @@ npx pm-xblox run --source=./graph.json --loglevel=info
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--source` | (required) | Path to a blocks JSON file (`version: 1`). |
|
||||
| `--output` | — | If set, writes `{ "result": … }` as JSON. |
|
||||
| `--output` | — | If set, writes `{ "result": …, "results": […] }` as JSON. |
|
||||
| `--loglevel` | `info` | `debug` · `info` · `warn` · `error` |
|
||||
|
||||
## Blocks file format
|
||||
@ -45,14 +45,20 @@ Top-level shape (see `src/schema/blocks-file.ts`):
|
||||
{
|
||||
"version": 1,
|
||||
"context": {},
|
||||
"root": { "kind": "…", … }
|
||||
"roots": [{ "kind": "…", … }, …]
|
||||
}
|
||||
```
|
||||
|
||||
- **`context`** — plain object merged into the execution `this` (expressions and scripts use **`this`** like the legacy `apply(ctx, args)` model).
|
||||
- **`root`** — a discriminated union on **`kind`**: `if`, `runScript`, `for`, `while`, `switch`, `break`, plus `case` / `switchDefault` inside `switch.items`.
|
||||
- **`roots`** — non-empty array of top-level blocks run **sequentially**, in order (shared `ctx`), like statements in a script. A UI can map one row per root to a collapsible tree of children.
|
||||
|
||||
**Legacy note:** older UIs often stored a **flat array** of blocks with `id`, `parentId`, `declaredClass`. This package currently executes the **nested** tree; you can add an adapter layer (flat → tree) for editors without changing the executor.
|
||||
Block **`kind`** values include: `if`, `runScript`, `for`, `while`, `switch`, `break`, `wait`, plus `case` / `switchDefault` inside `switch.items`.
|
||||
|
||||
- **`wait`** — `{ "kind": "wait", "ms": 5000 }` pauses for the given milliseconds (**async**; the executor is `async`).
|
||||
|
||||
**CLI / API output:** `executeRoots` and `pm-xblox run` return **`results`** (one entry per root) and **`result`** (last root’s return value).
|
||||
|
||||
**Legacy note:** older UIs often stored a **flat array** of blocks with `id`, `parentId`, `declaredClass`. This package executes **nested trees** under each root; you can add an adapter (flat → `roots`) for editors without changing the executor.
|
||||
|
||||
## Expressions and scripts
|
||||
|
||||
@ -72,10 +78,13 @@ Top-level shape (see `src/schema/blocks-file.ts`):
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run test:coverage` | Vitest with V8 coverage |
|
||||
| `npm run test:ui` | Vitest browser UI |
|
||||
| `npm run test:perf` | Runs `tests/unit/perf-report.test.ts` — prints a **`console.table`** of timings. With **`XBLOX_PERF_REPORT=1`**, also writes **`reports/perf-latest.json`** (gitignored). |
|
||||
| `npm run lint` | ESLint on `src/` |
|
||||
| `npm run webpack` | After `build`, emits `dist/main_node.js` from `dist-in/main.js` (default package exports only; **not** `@polymech/xblox/runtime` / vm2). |
|
||||
|
||||
Tests live under `tests/`; shared paths use `tests/test-commons.ts`. Fixtures are in `tests/fixtures/`.
|
||||
Tests live under `tests/`; shared paths and **performance helpers** (`PerfReporter`, `measureMs`, `fixture`) live in `tests/test-commons.ts`. Fixtures are in `tests/fixtures/`.
|
||||
|
||||
**Performance:** import `PerfReporter` in any test, wrap steps with `await perf.measure('label', () => …)`, then call `perf.printSummary()` in `afterAll`. Set **`XBLOX_PERF_REPORT=1`** when running tests to also persist **`reports/perf-latest.json`**.
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:perf": "vitest run tests/unit/perf-report.test.ts",
|
||||
"prepack": "npm run build",
|
||||
"webpack": "webpack --config webpack.config.js --stats-error-details"
|
||||
},
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeBlock } from '../engine/execute-block.js';
|
||||
import { executeRoots } from '../engine/execute-block.js';
|
||||
import { blocksFileSchema } from '../schema/blocks-file.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { runArgsSchema } from './run-args.js';
|
||||
|
||||
export async function runCommand(raw: Record<string, unknown>): Promise<{ result: unknown }> {
|
||||
export async function runCommand(
|
||||
raw: Record<string, unknown>,
|
||||
): Promise<{ result: unknown; results: unknown[] }> {
|
||||
const args = runArgsSchema.parse({
|
||||
source: raw.source,
|
||||
output: raw.output,
|
||||
@ -23,11 +25,11 @@ export async function runCommand(raw: Record<string, unknown>): Promise<{ result
|
||||
const file = blocksFileSchema.parse(json);
|
||||
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
const result = executeBlock(file.root, ctx);
|
||||
const { result, results } = await executeRoots(file.roots, ctx);
|
||||
|
||||
log.info('Execution finished.');
|
||||
|
||||
const payload = { result };
|
||||
const payload = { result, results };
|
||||
|
||||
if (args.output) {
|
||||
const outPath = path.resolve(args.output);
|
||||
|
||||
@ -10,6 +10,10 @@ export type ExecState = {
|
||||
switchCtl?: { stopped: boolean };
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
return Boolean(value);
|
||||
}
|
||||
@ -24,7 +28,7 @@ function forStep(counter: number | string, modifier: string, ctx: object): unkno
|
||||
return evalExpression(expr, ctx);
|
||||
}
|
||||
|
||||
function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, state?: ExecState): unknown {
|
||||
async function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, state?: ExecState): Promise<unknown> {
|
||||
let counter = coerceNumber(evalExpression(node.initial, ctx));
|
||||
if (Number.isNaN(counter)) {
|
||||
counter = 0;
|
||||
@ -32,7 +36,7 @@ function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, stat
|
||||
let last: unknown;
|
||||
while (forCondition(counter, node.comparator, node.final, ctx)) {
|
||||
const childState: ExecState = { ...state, loopIndex: counter };
|
||||
last = runBlockList(node.items, ctx, childState);
|
||||
last = await runBlockList(node.items, ctx, childState);
|
||||
const nextRaw = forStep(counter, node.modifier, ctx);
|
||||
const next = coerceNumber(nextRaw);
|
||||
if (Number.isNaN(next)) {
|
||||
@ -46,12 +50,12 @@ function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, stat
|
||||
return last;
|
||||
}
|
||||
|
||||
function executeWhile(node: Extract<BlockNode, { kind: 'while' }>, ctx: object, state?: ExecState): unknown {
|
||||
async function executeWhile(node: Extract<BlockNode, { kind: 'while' }>, ctx: object, state?: ExecState): Promise<unknown> {
|
||||
const limit = node.loopLimit ?? 1500;
|
||||
let iterations = 0;
|
||||
let last: unknown;
|
||||
while (isTruthy(evalExpression(node.condition, ctx)) && iterations < limit) {
|
||||
last = runBlockList(node.items, ctx, state);
|
||||
last = await runBlockList(node.items, ctx, state);
|
||||
iterations++;
|
||||
}
|
||||
return last;
|
||||
@ -65,7 +69,7 @@ function matchCase(ctx: object, variable: string, item: Pick<CaseBlockNode, 'com
|
||||
return isTruthy(evalExpression(combined, ctx));
|
||||
}
|
||||
|
||||
function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object, state?: ExecState): unknown {
|
||||
async function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object, state?: ExecState): Promise<unknown> {
|
||||
const switchCtl = state?.switchCtl ?? { stopped: false };
|
||||
const childState: ExecState = { ...state, switchCtl };
|
||||
let anyMatched = false;
|
||||
@ -74,7 +78,7 @@ function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object
|
||||
for (const item of node.items) {
|
||||
if (item.kind === 'case') {
|
||||
if (matchCase(ctx, node.variable, item)) {
|
||||
last = runBlockList(item.consequent, ctx, childState);
|
||||
last = await runBlockList(item.consequent, ctx, childState);
|
||||
anyMatched = true;
|
||||
break;
|
||||
}
|
||||
@ -87,7 +91,7 @@ function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object
|
||||
if (!anyMatched) {
|
||||
for (const item of node.items) {
|
||||
if (item.kind === 'switchDefault') {
|
||||
last = runBlockList(item.consequent, ctx, childState);
|
||||
last = await runBlockList(item.consequent, ctx, childState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -95,56 +99,61 @@ function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object
|
||||
return last;
|
||||
}
|
||||
|
||||
export function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): unknown {
|
||||
export async function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): Promise<unknown> {
|
||||
let last: unknown;
|
||||
for (const b of blocks) {
|
||||
last = executeBlock(b, ctx, state);
|
||||
last = await executeBlock(b, ctx, state);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tree starting at `root` with `ctx` as `this` in expressions and runScript blocks.
|
||||
* Execute one block node with `ctx` as `this` in expressions and runScript blocks.
|
||||
*/
|
||||
export function executeBlock(root: BlockNode, ctx: object, state?: ExecState): unknown {
|
||||
if (root.kind === 'runScript') {
|
||||
return runRunScriptBlock(new RunScriptBlock({ method: root.method }), ctx, []);
|
||||
export async function executeBlock(node: BlockNode, ctx: object, state?: ExecState): Promise<unknown> {
|
||||
if (node.kind === 'runScript') {
|
||||
return runRunScriptBlock(new RunScriptBlock({ method: node.method }), ctx, []);
|
||||
}
|
||||
|
||||
if (root.kind === 'break') {
|
||||
if (node.kind === 'wait') {
|
||||
await delay(node.ms);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.kind === 'break') {
|
||||
if (state?.switchCtl) {
|
||||
state.switchCtl.stopped = true;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (root.kind === 'for') {
|
||||
return executeFor(root, ctx, state);
|
||||
if (node.kind === 'for') {
|
||||
return executeFor(node, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'while') {
|
||||
return executeWhile(root, ctx, state);
|
||||
if (node.kind === 'while') {
|
||||
return executeWhile(node, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'switch') {
|
||||
return executeSwitch(root, ctx, state);
|
||||
if (node.kind === 'switch') {
|
||||
return executeSwitch(node, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'if') {
|
||||
if (isTruthy(evalExpression(root.condition, ctx))) {
|
||||
return runBlockList(root.consequent, ctx, state);
|
||||
if (node.kind === 'if') {
|
||||
if (isTruthy(evalExpression(node.condition, ctx))) {
|
||||
return runBlockList(node.consequent, ctx, state);
|
||||
}
|
||||
|
||||
if (root.elseIfBlocks) {
|
||||
for (const branch of root.elseIfBlocks) {
|
||||
if (node.elseIfBlocks) {
|
||||
for (const branch of node.elseIfBlocks) {
|
||||
if (isTruthy(evalExpression(branch.condition, ctx))) {
|
||||
return runBlockList(branch.consequent, ctx, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.alternate?.length) {
|
||||
return runBlockList(root.alternate, ctx, state);
|
||||
if (node.alternate?.length) {
|
||||
return runBlockList(node.alternate, ctx, state);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@ -152,3 +161,21 @@ export function executeBlock(root: BlockNode, ctx: object, state?: ExecState): u
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ExecuteRootsResult = {
|
||||
/** Return value of each top-level block, in order. */
|
||||
results: unknown[];
|
||||
/** Last top-level return (same as `results[results.length - 1]`). */
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
/** Run many top-level blocks in order (shared `ctx`), e.g. UI “run all” / script-like flow. */
|
||||
export async function executeRoots(roots: BlockNode[], ctx: object): Promise<ExecuteRootsResult> {
|
||||
const results: unknown[] = [];
|
||||
let result: unknown;
|
||||
for (const block of roots) {
|
||||
result = await executeBlock(block, ctx);
|
||||
results.push(result);
|
||||
}
|
||||
return { results, result };
|
||||
}
|
||||
|
||||
@ -3,3 +3,11 @@ export * from './types.js';
|
||||
export * from './uuid.js';
|
||||
export * from './model/model-base.js';
|
||||
export * from './blocks/run-script-block.js';
|
||||
export {
|
||||
executeBlock,
|
||||
executeRoots,
|
||||
runBlockList,
|
||||
type ExecState,
|
||||
type ExecuteRootsResult,
|
||||
} from './engine/execute-block.js';
|
||||
export { blocksFileSchema, type BlocksFile, type BlockNode } from './schema/blocks-file.js';
|
||||
|
||||
@ -60,6 +60,11 @@ export type BlockNode =
|
||||
/** Legacy `xblox.model.logic.BreakBlock`. */
|
||||
| {
|
||||
kind: 'break';
|
||||
}
|
||||
/** Async delay between steps (milliseconds). */
|
||||
| {
|
||||
kind: 'wait';
|
||||
ms: number;
|
||||
};
|
||||
|
||||
export const blockNodeSchema: z.ZodType<BlockNode> = z.lazy(() =>
|
||||
@ -119,13 +124,17 @@ export const blockNodeSchema: z.ZodType<BlockNode> = z.lazy(() =>
|
||||
z.object({
|
||||
kind: z.literal('break'),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('wait'),
|
||||
ms: z.number().nonnegative().finite(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export const blocksFileSchema = z.object({
|
||||
version: z.literal(1),
|
||||
context: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
root: blockNodeSchema,
|
||||
roots: z.array(blockNodeSchema).min(1),
|
||||
});
|
||||
|
||||
export type BlocksFile = z.infer<typeof blocksFileSchema>;
|
||||
|
||||
6
packages/xblox/tests/fixtures/for-sum.json
vendored
6
packages/xblox/tests/fixtures/for-sum.json
vendored
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "x": 0 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "for",
|
||||
"initial": "0",
|
||||
"final": "3",
|
||||
@ -13,5 +14,6 @@
|
||||
"method": "this.x = (this.x || 0) + 1; return this.x;"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": 0 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "if",
|
||||
"condition": "this.n > 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
|
||||
@ -12,5 +13,6 @@
|
||||
}
|
||||
],
|
||||
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
packages/xblox/tests/fixtures/if-else.json
vendored
6
packages/xblox/tests/fixtures/if-else.json
vendored
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": -3 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "if",
|
||||
"condition": "this.n > 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
|
||||
@ -12,5 +13,6 @@
|
||||
}
|
||||
],
|
||||
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
packages/xblox/tests/fixtures/multi-root-wait.json
vendored
Normal file
18
packages/xblox/tests/fixtures/multi-root-wait.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "steps": [] },
|
||||
"roots": [
|
||||
{
|
||||
"kind": "runScript",
|
||||
"method": "this.steps = this.steps || []; this.steps.push('a'); return 'a';"
|
||||
},
|
||||
{
|
||||
"kind": "wait",
|
||||
"ms": 5000
|
||||
},
|
||||
{
|
||||
"kind": "runScript",
|
||||
"method": "this.steps.push('b'); return 'b';"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "mode": 2 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "switch",
|
||||
"variable": "mode",
|
||||
"items": [
|
||||
@ -22,5 +23,6 @@
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "mode": 99 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "switch",
|
||||
"variable": "mode",
|
||||
"items": [
|
||||
@ -16,5 +17,6 @@
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": 0 },
|
||||
"root": {
|
||||
"roots": [
|
||||
{
|
||||
"kind": "while",
|
||||
"condition": "this.n < 3",
|
||||
"items": [
|
||||
@ -10,5 +11,6 @@
|
||||
"method": "this.n++; return this.n;"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Shared paths and helpers for vitest (import from tests only).
|
||||
* Shared paths, performance measurement, and optional JSON reports for vitest.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@ -12,6 +13,97 @@ export const testsDir = __dirname;
|
||||
|
||||
export const fixturesDir = path.join(testsDir, 'fixtures');
|
||||
|
||||
/** Default JSON output: `packages/xblox/reports/perf-latest.json` (gitignored). */
|
||||
export const defaultPerfReportPath = path.join(testsDir, '..', 'reports', 'perf-latest.json');
|
||||
|
||||
export function fixture(...parts: string[]): string {
|
||||
return path.join(fixturesDir, ...parts);
|
||||
}
|
||||
|
||||
// ——— performance ———
|
||||
|
||||
export type PerfEntry = {
|
||||
/** Logical label (e.g. suite step name). */
|
||||
name: string;
|
||||
/** Duration in milliseconds (`performance.now()` delta). */
|
||||
ms: number;
|
||||
/** ISO timestamp when the measurement finished. */
|
||||
at: string;
|
||||
};
|
||||
|
||||
/** When set to `1`, `PerfReporter.writeJsonFile()` writes to disk (see `npm run test:perf`). */
|
||||
export function isPerfReportEnabled(): boolean {
|
||||
return process.env.XBLOX_PERF_REPORT === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure sync or async work; returns the function result and duration.
|
||||
* Does not record to a report — use `PerfReporter.measure` for that.
|
||||
*/
|
||||
export async function measureMs<T>(fn: () => T | Promise<T>): Promise<{ result: T; ms: number }> {
|
||||
const t0 = performance.now();
|
||||
const result = await fn();
|
||||
const ms = performance.now() - t0;
|
||||
return { result, ms };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect timings for a test file or suite. Call `printSummary()` in `afterAll`,
|
||||
* and optionally `writeJsonFile()` when `XBLOX_PERF_REPORT=1`.
|
||||
*/
|
||||
export class PerfReporter {
|
||||
private readonly entries: PerfEntry[] = [];
|
||||
|
||||
get snapshot(): readonly PerfEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/** Run `fn`, record duration under `name`, return `fn`’s result. */
|
||||
async measure<T>(name: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
const { result, ms } = await measureMs(fn);
|
||||
this.entries.push({
|
||||
name,
|
||||
ms,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Log a compact table to stdout (Vitest / CI). */
|
||||
printSummary(title = 'xblox perf'): void {
|
||||
if (this.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rows = this.entries.map((e) => ({
|
||||
name: e.name,
|
||||
ms: Number(e.ms.toFixed(3)),
|
||||
at: e.at,
|
||||
}));
|
||||
console.info(`\n[${title}]`);
|
||||
console.table(rows);
|
||||
const total = this.entries.reduce((s, e) => s + e.ms, 0);
|
||||
console.info(`[${title}] total: ${total.toFixed(3)} ms\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `reports/perf-latest.json` (or `path`). Safe to call from `afterAll`.
|
||||
* Set env `XBLOX_PERF_REPORT=1` to enable writes; otherwise no-op (avoids churn in CI).
|
||||
*/
|
||||
writeJsonFile(filePath: string = defaultPerfReportPath): string | undefined {
|
||||
if (!isPerfReportEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: this.entries.map((e) => ({
|
||||
name: e.name,
|
||||
ms: e.ms,
|
||||
at: e.at,
|
||||
})),
|
||||
totalMs: this.entries.reduce((s, e) => s + e.ms, 0),
|
||||
};
|
||||
writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,6 @@ describe('pm-xblox run (runCommand)', () => {
|
||||
loglevel: 'error',
|
||||
});
|
||||
const text = await readFile(outPath, 'utf8');
|
||||
expect(JSON.parse(text)).toEqual({ result: 'zero' });
|
||||
expect(JSON.parse(text)).toEqual({ result: 'zero', results: ['zero'] });
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,13 +11,13 @@ describe('if / else-if / alternate engine', () => {
|
||||
const raw = JSON.parse(await readFile(fixture('if-else.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('neg');
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe('neg');
|
||||
});
|
||||
|
||||
it('evaluates alternate when no branch matches', async () => {
|
||||
const raw = JSON.parse(await readFile(fixture('if-else-zero.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('zero');
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe('zero');
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@ describe('ForBlock (legacy xblox.model.loops.ForBlock)', () => {
|
||||
const raw = JSON.parse(await readFile(fixture('for-sum.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe(3);
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe(3);
|
||||
expect((ctx as { x: number }).x).toBe(3);
|
||||
});
|
||||
});
|
||||
@ -20,7 +20,7 @@ describe('WhileBlock (legacy xblox.model.loops.WhileBlock)', () => {
|
||||
const raw = JSON.parse(await readFile(fixture('while-count.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe(3);
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe(3);
|
||||
expect((ctx as { n: number }).n).toBe(3);
|
||||
});
|
||||
});
|
||||
@ -30,14 +30,14 @@ describe('SwitchBlock / CaseBlock / DefaultBlock (legacy logic)', () => {
|
||||
const raw = JSON.parse(await readFile(fixture('switch-case.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('two');
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe('two');
|
||||
});
|
||||
|
||||
it('runs switchDefault when no case matches', async () => {
|
||||
const raw = JSON.parse(await readFile(fixture('switch-default-only.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('def');
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe('def');
|
||||
});
|
||||
});
|
||||
|
||||
@ -46,28 +46,30 @@ describe('BreakBlock', () => {
|
||||
const file = blocksFileSchema.parse({
|
||||
version: 1,
|
||||
context: { mode: 1 },
|
||||
root: {
|
||||
kind: 'switch',
|
||||
variable: 'mode',
|
||||
items: [
|
||||
{
|
||||
kind: 'case',
|
||||
comparator: '==',
|
||||
expression: '1',
|
||||
consequent: [
|
||||
{ kind: 'runScript', method: "return 'hit';" },
|
||||
{ kind: 'break' },
|
||||
{ kind: 'runScript', method: "return 'after';" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'switchDefault',
|
||||
consequent: [{ kind: 'runScript', method: "return 'def';" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
roots: [
|
||||
{
|
||||
kind: 'switch',
|
||||
variable: 'mode',
|
||||
items: [
|
||||
{
|
||||
kind: 'case',
|
||||
comparator: '==',
|
||||
expression: '1',
|
||||
consequent: [
|
||||
{ kind: 'runScript', method: "return 'hit';" },
|
||||
{ kind: 'break' },
|
||||
{ kind: 'runScript', method: "return 'after';" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'switchDefault',
|
||||
consequent: [{ kind: 'runScript', method: "return 'def';" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('after');
|
||||
expect(await executeBlock(file.roots[0], ctx)).toBe('after');
|
||||
});
|
||||
});
|
||||
|
||||
30
packages/xblox/tests/unit/multi-root-wait.test.ts
Normal file
30
packages/xblox/tests/unit/multi-root-wait.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { executeRoots } from '../../src/engine/execute-block.js';
|
||||
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
|
||||
import { fixture } from '../test-commons.js';
|
||||
|
||||
describe('multiple roots + wait', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs each top-level block in order; wait(ms) is async (5000ms)', async () => {
|
||||
const raw = JSON.parse(await readFile(fixture('multi-root-wait.json'), 'utf8'));
|
||||
const file = blocksFileSchema.parse(raw);
|
||||
const ctx = Object.assign(Object.create(null), file.context) as { steps: string[] };
|
||||
|
||||
const run = executeRoots(file.roots, ctx);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
const out = await run;
|
||||
|
||||
expect(out.results).toEqual(['a', undefined, 'b']);
|
||||
expect(out.result).toBe('b');
|
||||
expect(ctx.steps).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
47
packages/xblox/tests/unit/perf-report.test.ts
Normal file
47
packages/xblox/tests/unit/perf-report.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { executeRoots } from '../../src/engine/execute-block.js';
|
||||
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
|
||||
import { fixture, PerfReporter } from '../test-commons.js';
|
||||
|
||||
const perf = new PerfReporter();
|
||||
|
||||
describe('performance (test-commons reporter)', () => {
|
||||
afterAll(() => {
|
||||
perf.printSummary('xblox');
|
||||
perf.writeJsonFile();
|
||||
});
|
||||
|
||||
it('parses and runs representative fixtures under measurement', async () => {
|
||||
const ifElseFile = await perf.measure('parse if-else.json', async () => {
|
||||
const raw = JSON.parse(await readFile(fixture('if-else.json'), 'utf8'));
|
||||
return blocksFileSchema.parse(raw);
|
||||
});
|
||||
|
||||
const ctx1 = Object.assign(Object.create(null), ifElseFile.context) as object;
|
||||
await perf.measure('executeRoots if-else', async () => {
|
||||
return executeRoots(ifElseFile.roots, ctx1);
|
||||
});
|
||||
|
||||
const multiRaw = await perf.measure('parse multi-root-wait.json', async () => {
|
||||
const raw = JSON.parse(await readFile(fixture('multi-root-wait.json'), 'utf8'));
|
||||
return blocksFileSchema.parse(raw);
|
||||
});
|
||||
|
||||
const ctx2 = Object.assign(Object.create(null), multiRaw.context) as object;
|
||||
await perf.measure('executeRoots multi-root (sync parts only)', async () => {
|
||||
return executeRoots(
|
||||
[
|
||||
multiRaw.roots[0],
|
||||
{ kind: 'wait' as const, ms: 0 },
|
||||
multiRaw.roots[2],
|
||||
],
|
||||
ctx2,
|
||||
);
|
||||
});
|
||||
|
||||
expect(ifElseFile.roots).toHaveLength(1);
|
||||
expect(multiRaw.roots).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user