xblox 1/2
This commit is contained in:
parent
efeafbb725
commit
968268d7ca
5
packages/xblox/.gitignore
vendored
Normal file
5
packages/xblox/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-in/
|
||||
coverage/
|
||||
*.log
|
||||
13
packages/xblox/.npmignore
Normal file
13
packages/xblox/.npmignore
Normal file
@ -0,0 +1,13 @@
|
||||
# This package uses package.json "files" as an allowlist (dist-in, README, LICENSE).
|
||||
# These patterns apply if "files" is ever removed or relaxed — keep junk out of the tarball.
|
||||
|
||||
ref-control-freak/
|
||||
tests/
|
||||
coverage/
|
||||
src/
|
||||
.vscode/
|
||||
*.test.ts
|
||||
vitest.config.ts
|
||||
webpack.config.js
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
21
packages/xblox/LICENSE
Normal file
21
packages/xblox/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Polymech
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
89
packages/xblox/README.md
Normal file
89
packages/xblox/README.md
Normal file
@ -0,0 +1,89 @@
|
||||
# @polymech/xblox
|
||||
|
||||
Block-based control flow for Polymech: **if / else-if**, **for / while**, **switch / case / default**, **runScript**, and **break**, with a **nested JSON** graph format, **Zod** validation, and a small **Node** CLI.
|
||||
|
||||
This package is a modern take on ideas from the legacy **xblox** AMD stack (`ref-control-freak/` in this repo). It does **not** reimplement the full legacy `Expression.js` pipeline (`[variables]`, `{block}` substitution, block-store UI); it focuses on **execution** and **schema**.
|
||||
|
||||
## Install
|
||||
|
||||
From the monorepo, using **npm** in this package:
|
||||
|
||||
```bash
|
||||
cd packages/xblox
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Package exports
|
||||
|
||||
| Import | Use |
|
||||
|--------|-----|
|
||||
| `@polymech/xblox` | Types, enums, `ModelBase`, `RunScriptBlock`, engine entrypoints you re-export from app code. **No `vm2`.** |
|
||||
| `@polymech/xblox/runtime` | `runScriptLegacyVm`, `evalExpression`, `runRunScriptBlock` — **Node only**, uses **`vm2`**. Do not import this path in browser bundles if you must avoid `vm2`. |
|
||||
|
||||
## CLI: `pm-xblox`
|
||||
|
||||
After `npm run build`, the `pm-xblox` binary runs validated graphs from disk.
|
||||
|
||||
```bash
|
||||
npx pm-xblox run --source=./graph.json
|
||||
npx pm-xblox run --source=./graph.json --output=./out.json
|
||||
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. |
|
||||
| `--loglevel` | `info` | `debug` · `info` · `warn` · `error` |
|
||||
|
||||
## Blocks file format
|
||||
|
||||
Top-level shape (see `src/schema/blocks-file.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"context": {},
|
||||
"root": { "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`.
|
||||
|
||||
**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.
|
||||
|
||||
## Expressions and scripts
|
||||
|
||||
- Conditions and `runScript.method` bodies are evaluated via **`vm2`** in **`@polymech/xblox/runtime`**, following the same **`new Function("{ … }").apply(ctx, args)`** idea as legacy Run Script blocks.
|
||||
- This is **not** a port of legacy `Expression.js` (no `[var]` substitution, `{block}` calls, or expression cache). For full parity with that layer, plan a separate module or bridge.
|
||||
|
||||
**Security:** `vm2` is unmaintained; treat user scripts as trusted only in controlled environments, or replace the runtime with a stricter evaluator later.
|
||||
|
||||
## Development
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run build` | `tsc` → `dist-in/` |
|
||||
| `npm run dev` | `tsc --watch` |
|
||||
| `npm run clean` | Remove `dist/` and `dist-in/` |
|
||||
| `npm test` | Run Vitest once |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run test:coverage` | Vitest with V8 coverage |
|
||||
| `npm run test:ui` | Vitest browser UI |
|
||||
| `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/`.
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
- **`package.json` → `files`** is an **allowlist**: only `dist-in/`, `README.md`, and `LICENSE` are packed. **`ref-control-freak/`**, **`tests/`**, **`src/`**, webpack/vitest configs, etc. are **not** published.
|
||||
- **`prepack`** runs **`npm run build`**, so `dist-in/` exists when you `npm publish` or `npm pack`.
|
||||
- **Dry-run the tarball:** `npm pack --dry-run` (see “Files included” in the log) or `npm pack` and inspect the `.tgz`.
|
||||
- **`dist/`** (webpack output) is intentionally **not** in `files`; consumers use `dist-in/` from `tsc`.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [`LICENSE`](./LICENSE).
|
||||
5037
packages/xblox/package-lock.json
generated
Normal file
5037
packages/xblox/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
packages/xblox/package.json
Normal file
79
packages/xblox/package.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@polymech/xblox",
|
||||
"version": "0.1.0",
|
||||
"description": "Block control-flow engine (if/for/while/switch/runScript) with Zod schemas, vm2-backed Node runtime, and pm-xblox CLI.",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Polymech",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist-in",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.polymech.io/polymech/mono.git",
|
||||
"directory": "packages/xblox"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist-in/index.d.ts",
|
||||
"import": "./dist-in/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./dist-in/runtime/index.d.ts",
|
||||
"import": "./dist-in/runtime/index.js"
|
||||
}
|
||||
},
|
||||
"types": "./dist-in/index.d.ts",
|
||||
"main": "./dist-in/index.js",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsc -p .",
|
||||
"dev": "tsc -p . --watch",
|
||||
"clean": "rimraf dist dist-in",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"prepack": "npm run build",
|
||||
"webpack": "webpack --config webpack.config.js --stats-error-details"
|
||||
},
|
||||
"bin": {
|
||||
"pm-xblox": "./dist-in/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"vm2": "^3.9.19",
|
||||
"yargs": "17.7.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "file:../typescript-config",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
"@vitest/ui": "4.1.0",
|
||||
"eslint": "^8.57.1",
|
||||
"rimraf": "6.0.1",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "4.1.0",
|
||||
"webpack": "5.105.4",
|
||||
"webpack-cli": "6.0.1"
|
||||
},
|
||||
"keywords": [
|
||||
"xblox",
|
||||
"visual-programming",
|
||||
"blocks",
|
||||
"flow",
|
||||
"control-flow",
|
||||
"zod",
|
||||
"vitest"
|
||||
]
|
||||
}
|
||||
24
packages/xblox/src/blocks/run-script-block.ts
Normal file
24
packages/xblox/src/blocks/run-script-block.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Data model for legacy `xblox.model.code.RunScript` — no UI, no execution here.
|
||||
*/
|
||||
|
||||
import { ModelBase, type ModelBaseArgs } from '../model/model-base.js';
|
||||
|
||||
export interface RunScriptBlockArgs extends ModelBaseArgs {
|
||||
/** Script body (legacy field name `method`). */
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export class RunScriptBlock extends ModelBase {
|
||||
readonly declaredClass = 'xblox.model.code.RunScript' as const;
|
||||
name = 'Run Script';
|
||||
/** User script placed inside `new Function("{" + method + "}")` in the original Dojo code. */
|
||||
method = '';
|
||||
|
||||
constructor(args: RunScriptBlockArgs = {}) {
|
||||
super(args);
|
||||
if (args.method !== undefined) {
|
||||
this.method = args.method;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/xblox/src/cli.ts
Normal file
46
packages/xblox/src/cli.ts
Normal file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import { runCommand } from './cli/run-command.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await yargs(hideBin(process.argv))
|
||||
.scriptName('pm-xblox')
|
||||
.command(
|
||||
'run',
|
||||
'Execute a blocks JSON file (if / else-if / runScript)',
|
||||
(y) =>
|
||||
y
|
||||
.option('source', {
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
describe: 'Path to blocks JSON file',
|
||||
})
|
||||
.option('output', {
|
||||
type: 'string',
|
||||
describe: 'Optional path to write JSON { result }',
|
||||
})
|
||||
.option('loglevel', {
|
||||
type: 'string',
|
||||
default: 'info',
|
||||
describe: 'debug | info | warn | error',
|
||||
}),
|
||||
async (argv) => {
|
||||
await runCommand({
|
||||
source: argv.source,
|
||||
output: argv.output,
|
||||
loglevel: argv.loglevel,
|
||||
});
|
||||
},
|
||||
)
|
||||
.demandCommand(1, 'Specify a command (e.g. run)')
|
||||
.strict()
|
||||
.help()
|
||||
.parseAsync();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[pm-xblox]', err);
|
||||
process.exit(1);
|
||||
});
|
||||
50
packages/xblox/src/cli/logger.ts
Normal file
50
packages/xblox/src/cli/logger.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const rank: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function emit(level: LogLevel, args: unknown[]): void {
|
||||
const prefix = '[pm-xblox]';
|
||||
if (level === 'debug') {
|
||||
console.debug(prefix, ...args);
|
||||
} else if (level === 'info') {
|
||||
console.info(prefix, ...args);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(prefix, ...args);
|
||||
} else {
|
||||
console.error(prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages at or above `minLevel` are printed. Default CLI min is `info`. */
|
||||
export function createLogger(minLevel: LogLevel) {
|
||||
const min = rank[minLevel];
|
||||
const go = (msgLevel: LogLevel) => rank[msgLevel] >= min;
|
||||
|
||||
return {
|
||||
debug: (...a: unknown[]) => {
|
||||
if (go('debug')) {
|
||||
emit('debug', a);
|
||||
}
|
||||
},
|
||||
info: (...a: unknown[]) => {
|
||||
if (go('info')) {
|
||||
emit('info', a);
|
||||
}
|
||||
},
|
||||
warn: (...a: unknown[]) => {
|
||||
if (go('warn')) {
|
||||
emit('warn', a);
|
||||
}
|
||||
},
|
||||
error: (...a: unknown[]) => {
|
||||
if (go('error')) {
|
||||
emit('error', a);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
11
packages/xblox/src/cli/run-args.ts
Normal file
11
packages/xblox/src/cli/run-args.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
|
||||
|
||||
export const runArgsSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
output: z.string().optional(),
|
||||
loglevel: logLevelSchema.default('info'),
|
||||
});
|
||||
|
||||
export type RunArgs = z.infer<typeof runArgsSchema>;
|
||||
39
packages/xblox/src/cli/run-command.ts
Normal file
39
packages/xblox/src/cli/run-command.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { executeBlock } 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 }> {
|
||||
const args = runArgsSchema.parse({
|
||||
source: raw.source,
|
||||
output: raw.output,
|
||||
loglevel: raw.loglevel ?? 'info',
|
||||
});
|
||||
|
||||
const log = createLogger(args.loglevel);
|
||||
const sourcePath = path.resolve(args.source);
|
||||
|
||||
log.debug('Reading blocks file:', sourcePath);
|
||||
|
||||
const text = await readFile(sourcePath, 'utf8');
|
||||
const json: unknown = JSON.parse(text);
|
||||
const file = blocksFileSchema.parse(json);
|
||||
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
const result = executeBlock(file.root, ctx);
|
||||
|
||||
log.info('Execution finished.');
|
||||
|
||||
const payload = { result };
|
||||
|
||||
if (args.output) {
|
||||
const outPath = path.resolve(args.output);
|
||||
await writeFile(outPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
log.info('Wrote', outPath);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
10
packages/xblox/src/engine/coerce.ts
Normal file
10
packages/xblox/src/engine/coerce.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export function coerceNumber(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return NaN;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
154
packages/xblox/src/engine/execute-block.ts
Normal file
154
packages/xblox/src/engine/execute-block.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { RunScriptBlock } from '../blocks/run-script-block.js';
|
||||
import type { BlockNode, CaseBlockNode } from '../schema/blocks-file.js';
|
||||
import { evalExpression, runRunScriptBlock } from '../runtime/run-script-vm.js';
|
||||
import { coerceNumber } from './coerce.js';
|
||||
|
||||
export type ExecState = {
|
||||
/** Legacy ForBlock `settings.override.args[0]` — loop index. */
|
||||
loopIndex?: number;
|
||||
/** Set by `BreakBlock` for parent `SwitchBlock` (legacy `switchblock.stop()`). */
|
||||
switchCtl?: { stopped: boolean };
|
||||
};
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function forCondition(counter: number | string, comparator: string, finalStr: string, ctx: object): boolean {
|
||||
const condStr = `${counter}${comparator}${finalStr}`;
|
||||
return isTruthy(evalExpression(condStr, ctx));
|
||||
}
|
||||
|
||||
function forStep(counter: number | string, modifier: string, ctx: object): unknown {
|
||||
const expr = `${counter}${modifier}`;
|
||||
return evalExpression(expr, ctx);
|
||||
}
|
||||
|
||||
function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, state?: ExecState): unknown {
|
||||
let counter = coerceNumber(evalExpression(node.initial, ctx));
|
||||
if (Number.isNaN(counter)) {
|
||||
counter = 0;
|
||||
}
|
||||
let last: unknown;
|
||||
while (forCondition(counter, node.comparator, node.final, ctx)) {
|
||||
const childState: ExecState = { ...state, loopIndex: counter };
|
||||
last = runBlockList(node.items, ctx, childState);
|
||||
const nextRaw = forStep(counter, node.modifier, ctx);
|
||||
const next = coerceNumber(nextRaw);
|
||||
if (Number.isNaN(next)) {
|
||||
break;
|
||||
}
|
||||
if (next === counter) {
|
||||
break;
|
||||
}
|
||||
counter = next;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function executeWhile(node: Extract<BlockNode, { kind: 'while' }>, ctx: object, state?: ExecState): 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);
|
||||
iterations++;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Legacy CaseBlock.solve comparison: `switchValue + comparator + expression` evaluated as one expression. */
|
||||
function matchCase(ctx: object, variable: string, item: Pick<CaseBlockNode, 'comparator' | 'expression'>): boolean {
|
||||
const sv = evalExpression(`this.${variable}`, ctx);
|
||||
const rhs = evalExpression(item.expression, ctx);
|
||||
const combined = `${String(sv)}${item.comparator}${String(rhs)}`;
|
||||
return isTruthy(evalExpression(combined, ctx));
|
||||
}
|
||||
|
||||
function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object, state?: ExecState): unknown {
|
||||
const switchCtl = state?.switchCtl ?? { stopped: false };
|
||||
const childState: ExecState = { ...state, switchCtl };
|
||||
let anyMatched = false;
|
||||
let last: unknown;
|
||||
|
||||
for (const item of node.items) {
|
||||
if (item.kind === 'case') {
|
||||
if (matchCase(ctx, node.variable, item)) {
|
||||
last = runBlockList(item.consequent, ctx, childState);
|
||||
anyMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (switchCtl.stopped) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyMatched) {
|
||||
for (const item of node.items) {
|
||||
if (item.kind === 'switchDefault') {
|
||||
last = runBlockList(item.consequent, ctx, childState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
export function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): unknown {
|
||||
let last: unknown;
|
||||
for (const b of blocks) {
|
||||
last = executeBlock(b, ctx, state);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tree starting at `root` 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, []);
|
||||
}
|
||||
|
||||
if (root.kind === 'break') {
|
||||
if (state?.switchCtl) {
|
||||
state.switchCtl.stopped = true;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (root.kind === 'for') {
|
||||
return executeFor(root, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'while') {
|
||||
return executeWhile(root, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'switch') {
|
||||
return executeSwitch(root, ctx, state);
|
||||
}
|
||||
|
||||
if (root.kind === 'if') {
|
||||
if (isTruthy(evalExpression(root.condition, ctx))) {
|
||||
return runBlockList(root.consequent, ctx, state);
|
||||
}
|
||||
|
||||
if (root.elseIfBlocks) {
|
||||
for (const branch of root.elseIfBlocks) {
|
||||
if (isTruthy(evalExpression(branch.condition, ctx))) {
|
||||
return runBlockList(branch.consequent, ctx, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.alternate?.length) {
|
||||
return runBlockList(root.alternate, ctx, state);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
183
packages/xblox/src/enums.ts
Normal file
183
packages/xblox/src/enums.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Ported from ref-control-freak/xblox-ts (legacy xblox). Bitmasks and event names
|
||||
* shared by browser UI and server/runtime.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The block's capabilities — evaluated in the interface and at run-time.
|
||||
*/
|
||||
export enum BLOCK_CAPABILITIES {
|
||||
TOPMOST = 0x00004000,
|
||||
TARGET = 0x00040000,
|
||||
VARIABLE_INPUTS = 0x00000080,
|
||||
VARIABLE_OUTPUTS = 0x00000100,
|
||||
VARIABLE_OUTPUT_PARAMETERS = 0x00000200,
|
||||
VARIABLE_INPUT_PARAMETERS = 0x00000400,
|
||||
CHILDREN = 0x00000020,
|
||||
/** Same bitmask as VARIABLE_INPUTS in legacy sources. */
|
||||
SIGNALS = 0x00000080,
|
||||
}
|
||||
|
||||
/** Flags describing a block's execution behavior. */
|
||||
export enum RUN_FLAGS {
|
||||
CHILDREN = 0x00000020,
|
||||
/** Legacy literal preserved (see ref-control-freak/xblox/types/Types.js). */
|
||||
WAIT = 0x000008000,
|
||||
}
|
||||
|
||||
/** Flags describing a block's execution state. */
|
||||
export enum EXECUTION_STATE {
|
||||
NONE = 0x00000000,
|
||||
RUNNING = 0x00000001,
|
||||
ERROR = 0x00000002,
|
||||
PAUSED = 0x00000004,
|
||||
FINISH = 0x00000008,
|
||||
STOPPED = 0x00000010,
|
||||
ONCE = 0x80000000,
|
||||
RESET_NEXT_FRAME = 0x00800000,
|
||||
LOCKED = 0x20000000,
|
||||
}
|
||||
|
||||
export enum BLOCK_MODE {
|
||||
NORMAL = 0,
|
||||
UPDATE_WIDGET_PROPERTY = 1,
|
||||
}
|
||||
|
||||
/** Standard signal / outlet bitmask. */
|
||||
export enum BLOCK_OUTLET {
|
||||
NONE = 0x00000000,
|
||||
PROGRESS = 0x00000001,
|
||||
ERROR = 0x00000002,
|
||||
PAUSED = 0x00000004,
|
||||
FINISH = 0x00000008,
|
||||
STOPPED = 0x00000010,
|
||||
}
|
||||
|
||||
/** Inner state / optimization flags (legacy xide/xblox). */
|
||||
export enum BLOCK_FLAGS {
|
||||
NONE = 0x00000000,
|
||||
ACTIVE = 0x00000001,
|
||||
SCRIPT = 0x00000002,
|
||||
RESERVED1 = 0x00000004,
|
||||
USEFUNCTION = 0x00000008,
|
||||
RESERVED2 = 0x00000010,
|
||||
SINGLE = 0x00000020,
|
||||
WAITSFORMESSAGE = 0x00000040,
|
||||
VARIABLEINPUTS = 0x00000080,
|
||||
VARIABLEOUTPUTS = 0x00000100,
|
||||
VARIABLEPARAMETERINPUTS = 0x00000200,
|
||||
VARIABLEPARAMETEROUTPUTS = 0x00000400,
|
||||
TOPMOST = 0x00004000,
|
||||
BUILDINGBLOCK = 0x00008000,
|
||||
MESSAGESENDER = 0x00010000,
|
||||
MESSAGERECEIVER = 0x00020000,
|
||||
TARGETABLE = 0x00040000,
|
||||
CUSTOMEDITDIALOG = 0x00080000,
|
||||
RESERVED0 = 0x00100000,
|
||||
EXECUTEDLASTFRAME = 0x00200000,
|
||||
DEACTIVATENEXTFRAME = 0x00400000,
|
||||
RESETNEXTFRAME = 0x00800000,
|
||||
INTERNALLYCREATEDINPUTS = 0x01000000,
|
||||
INTERNALLYCREATEDOUTPUTS = 0x02000000,
|
||||
INTERNALLYCREATEDINPUTPARAMS = 0x04000000,
|
||||
INTERNALLYCREATEDOUTPUTPARAMS = 0x08000000,
|
||||
INTERNALLYCREATEDLOCALPARAMS = 0x40000000,
|
||||
ACTIVATENEXTFRAME = 0x10000000,
|
||||
LOCKED = 0x20000000,
|
||||
LAUNCHEDONCE = 0x80000000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback mask for block lifecycle (legacy EventedMixin `emits` chain).
|
||||
* Note: legacy sources duplicate 0x00001000 for RESET and READSTATE.
|
||||
*/
|
||||
export enum BLOCK_CALLBACKMASK {
|
||||
PRESAVE = 0x00000001,
|
||||
DELETE = 0x00000002,
|
||||
ATTACH = 0x00000004,
|
||||
DETACH = 0x00000008,
|
||||
PAUSE = 0x00000010,
|
||||
RESUME = 0x00000020,
|
||||
CREATE = 0x00000040,
|
||||
POSTSAVE = 0x00000100,
|
||||
LOAD = 0x00000200,
|
||||
EDITED = 0x00000400,
|
||||
SETTINGSEDITED = 0x00000800,
|
||||
RESET = 0x00001000,
|
||||
READSTATE = 0x00001000,
|
||||
NEWSCENE = 0x00002000,
|
||||
ACTIVATESCRIPT = 0x00004000,
|
||||
DEACTIVATESCRIPT = 0x00008000,
|
||||
RESETINBREAKPOINT = 0x00010000,
|
||||
RENAME = 0x00020000,
|
||||
BASE = 0x0000000e,
|
||||
SAVELOAD = 0x00000301,
|
||||
PPR = 0x00000130,
|
||||
EDITIONS = 0x00000c00,
|
||||
ALL = 0xffffffff,
|
||||
}
|
||||
|
||||
/** String event names (topic-style), from legacy `types.EVENTS`. */
|
||||
export const EVENTS = {
|
||||
ON_RUN_BLOCK: 'onRunBlock',
|
||||
ON_RUN_BLOCK_FAILED: 'onRunBlockFailed',
|
||||
ON_RUN_BLOCK_SUCCESS: 'onRunBlockSuccess',
|
||||
ON_BLOCK_SELECTED: 'onItemSelected',
|
||||
ON_BLOCK_UNSELECTED: 'onBlockUnSelected',
|
||||
ON_BLOCK_EXPRESSION_FAILED: 'onExpressionFailed',
|
||||
ON_BUILD_BLOCK_INFO_LIST: 'onBuildBlockInfoList',
|
||||
ON_BUILD_BLOCK_INFO_LIST_END: 'onBuildBlockInfoListEnd',
|
||||
ON_BLOCK_PROPERTY_CHANGED: 'onBlockPropertyChanged',
|
||||
ON_SCOPE_CREATED: 'onScopeCreated',
|
||||
ON_VARIABLE_CHANGED: 'onVariableChanged',
|
||||
ON_CREATE_VARIABLE_CI: 'onCreateVariableCI',
|
||||
} as const;
|
||||
|
||||
export type XbloxEventName = (typeof EVENTS)[keyof typeof EVENTS];
|
||||
|
||||
/**
|
||||
* ESTree-style node type strings used by the legacy block expression layer.
|
||||
* Renamed from `Type` in xblox-ts to avoid clashing with TypeScript's `Type`.
|
||||
*/
|
||||
export enum BlockAstType {
|
||||
AssignmentExpression = 'AssignmentExpression',
|
||||
ArrayExpression = 'ArrayExpression',
|
||||
BlockStatement = 'BlockStatement',
|
||||
BinaryExpression = 'BinaryExpression',
|
||||
BreakStatement = 'BreakStatement',
|
||||
CallExpression = 'CallExpression',
|
||||
CatchClause = 'CatchClause',
|
||||
ConditionalExpression = 'ConditionalExpression',
|
||||
ContinueStatement = 'ContinueStatement',
|
||||
DoWhileStatement = 'DoWhileStatement',
|
||||
DebuggerStatement = 'DebuggerStatement',
|
||||
EmptyStatement = 'EmptyStatement',
|
||||
ExpressionStatement = 'ExpressionStatement',
|
||||
ForStatement = 'ForStatement',
|
||||
ForInStatement = 'ForInStatement',
|
||||
FunctionDeclaration = 'FunctionDeclaration',
|
||||
FunctionExpression = 'FunctionExpression',
|
||||
Identifier = 'Identifier',
|
||||
IfStatement = 'IfStatement',
|
||||
Literal = 'Literal',
|
||||
LabeledStatement = 'LabeledStatement',
|
||||
LogicalExpression = 'LogicalExpression',
|
||||
MemberExpression = 'MemberExpression',
|
||||
NewExpression = 'NewExpression',
|
||||
ObjectExpression = 'ObjectExpression',
|
||||
Program = 'Program',
|
||||
Property = 'Property',
|
||||
ReturnStatement = 'ReturnStatement',
|
||||
SequenceExpression = 'SequenceExpression',
|
||||
SwitchStatement = 'SwitchStatement',
|
||||
SwitchCase = 'SwitchCase',
|
||||
ThisExpression = 'ThisExpression',
|
||||
ThrowStatement = 'ThrowStatement',
|
||||
TryStatement = 'TryStatement',
|
||||
UnaryExpression = 'UnaryExpression',
|
||||
UpdateExpression = 'UpdateExpression',
|
||||
VariableDeclaration = 'VariableDeclaration',
|
||||
VariableDeclarator = 'VariableDeclarator',
|
||||
WhileStatement = 'WhileStatement',
|
||||
WithStatement = 'WithStatement',
|
||||
}
|
||||
5
packages/xblox/src/index.ts
Normal file
5
packages/xblox/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './enums.js';
|
||||
export * from './types.js';
|
||||
export * from './uuid.js';
|
||||
export * from './model/model-base.js';
|
||||
export * from './blocks/run-script-block.js';
|
||||
4
packages/xblox/src/main.ts
Normal file
4
packages/xblox/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Optional Node bundle entry (see webpack.config.js). Re-exports the public API.
|
||||
*/
|
||||
export * from './index.js';
|
||||
48
packages/xblox/src/model/model-base.ts
Normal file
48
packages/xblox/src/model/model-base.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Minimal port of `xblox/model/ModelBase` — eventing and store wiring come later.
|
||||
*/
|
||||
|
||||
import type { PortDescriptor } from '../types.js';
|
||||
import { randomUUID } from '../uuid.js';
|
||||
|
||||
export interface ModelBaseArgs {
|
||||
id?: string;
|
||||
description?: string;
|
||||
parent?: unknown;
|
||||
parentId?: string | null;
|
||||
group?: string | null;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export abstract class ModelBase {
|
||||
id: string;
|
||||
description: string;
|
||||
parent: unknown;
|
||||
parentId: string | null;
|
||||
group: string | null;
|
||||
order: number;
|
||||
|
||||
protected constructor(args: ModelBaseArgs = {}) {
|
||||
this.description = args.description ?? '';
|
||||
this.parent = args.parent ?? null;
|
||||
this.parentId = args.parentId ?? null;
|
||||
this.group = args.group ?? null;
|
||||
this.order = args.order ?? 0;
|
||||
this.id = args.id ?? randomUUID();
|
||||
}
|
||||
|
||||
/** Full input signature (Dojo SMD–style array). */
|
||||
takes(): PortDescriptor[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Required inputs — default: same as `takes()`. */
|
||||
needs(): PortDescriptor[] {
|
||||
return this.takes();
|
||||
}
|
||||
|
||||
/** Output signature. */
|
||||
outputs(): PortDescriptor[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
6
packages/xblox/src/runtime/index.ts
Normal file
6
packages/xblox/src/runtime/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export {
|
||||
evalExpression,
|
||||
runRunScriptBlock,
|
||||
runScriptLegacyVm,
|
||||
type RunScriptVmOptions,
|
||||
} from './run-script-vm.js';
|
||||
71
packages/xblox/src/runtime/run-script-vm.ts
Normal file
71
packages/xblox/src/runtime/run-script-vm.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Node-only: run legacy Run Script blocks inside vm2 (replaces raw `new Function` in the browser build).
|
||||
* Import from `@polymech/xblox/runtime` so browser bundles do not pull vm2.
|
||||
*/
|
||||
|
||||
import { NodeVM } from 'vm2';
|
||||
import type { RunScriptBlock } from '../blocks/run-script-block.js';
|
||||
|
||||
export interface RunScriptVmOptions {
|
||||
/** Extra globals merged into the vm sandbox (in addition to `ctx` / `args`). */
|
||||
sandbox?: Record<string, unknown>;
|
||||
/** vm2 NodeVM options (merged over safe defaults). */
|
||||
vm?: Partial<import('vm2').NodeVMOptions>;
|
||||
}
|
||||
|
||||
const defaultVmOptions = (): Partial<import('vm2').NodeVMOptions> => ({
|
||||
console: 'off',
|
||||
sandbox: {},
|
||||
require: false,
|
||||
nesting: false,
|
||||
wrapper: 'commonjs',
|
||||
});
|
||||
|
||||
/**
|
||||
* Matches legacy `xblox/model/code/RunScript`: `new Function("{" + script + "}").apply(ctx, args)`.
|
||||
*/
|
||||
export function runScriptLegacyVm(
|
||||
script: string,
|
||||
ctx: object,
|
||||
args: unknown[],
|
||||
options: RunScriptVmOptions = {},
|
||||
): unknown {
|
||||
const body = `{${script}}`;
|
||||
const sandbox = {
|
||||
ctx,
|
||||
args,
|
||||
...(options.sandbox ?? {}),
|
||||
...(options.vm?.sandbox as Record<string, unknown> | undefined),
|
||||
};
|
||||
const vm = new NodeVM({
|
||||
...defaultVmOptions(),
|
||||
...options.vm,
|
||||
sandbox,
|
||||
});
|
||||
|
||||
const code = `
|
||||
const fn = new Function(${JSON.stringify(body)});
|
||||
module.exports = fn.apply(ctx, args);
|
||||
`;
|
||||
|
||||
return vm.run(code, 'xblox-run-script.js');
|
||||
}
|
||||
|
||||
export function runRunScriptBlock(
|
||||
block: RunScriptBlock,
|
||||
ctx: object,
|
||||
args: unknown[] = [],
|
||||
options?: RunScriptVmOptions,
|
||||
): unknown {
|
||||
return runScriptLegacyVm(block.method, ctx, args, options);
|
||||
}
|
||||
|
||||
/** Evaluate a single expression with `this` bound to `ctx` (legacy `parseExpression`–style). */
|
||||
export function evalExpression(
|
||||
expr: string,
|
||||
ctx: object,
|
||||
args: unknown[] = [],
|
||||
options?: RunScriptVmOptions,
|
||||
): unknown {
|
||||
return runScriptLegacyVm(`return (${expr});`, ctx, args, options);
|
||||
}
|
||||
131
packages/xblox/src/schema/blocks-file.ts
Normal file
131
packages/xblox/src/schema/blocks-file.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ElseIfBlockNode = {
|
||||
condition: string;
|
||||
consequent: BlockNode[];
|
||||
};
|
||||
|
||||
/** Legacy `xblox.model.logic.CaseBlock` — one branch inside a switch. */
|
||||
export type CaseBlockNode = {
|
||||
kind: 'case';
|
||||
comparator: string;
|
||||
expression: string;
|
||||
consequent: BlockNode[];
|
||||
};
|
||||
|
||||
/** Legacy `xblox.model.logic.DefaultBlock`. */
|
||||
export type SwitchDefaultNode = {
|
||||
kind: 'switchDefault';
|
||||
consequent: BlockNode[];
|
||||
};
|
||||
|
||||
export type SwitchItemNode = CaseBlockNode | SwitchDefaultNode;
|
||||
|
||||
export type BlockNode =
|
||||
| {
|
||||
kind: 'if';
|
||||
condition: string;
|
||||
consequent: BlockNode[];
|
||||
elseIfBlocks?: ElseIfBlockNode[];
|
||||
alternate?: BlockNode[];
|
||||
}
|
||||
| {
|
||||
kind: 'runScript';
|
||||
method: string;
|
||||
id?: string;
|
||||
}
|
||||
/** Legacy `xblox.model.loops.ForBlock` — synchronous numeric loop. */
|
||||
| {
|
||||
kind: 'for';
|
||||
initial: string;
|
||||
final: string;
|
||||
comparator: string;
|
||||
modifier: string;
|
||||
items: BlockNode[];
|
||||
ignoreErrors?: boolean;
|
||||
}
|
||||
/** Legacy `xblox.model.loops.WhileBlock`. */
|
||||
| {
|
||||
kind: 'while';
|
||||
condition: string;
|
||||
items: BlockNode[];
|
||||
loopLimit?: number;
|
||||
}
|
||||
/** Legacy `xblox.model.logic.SwitchBlock`. */
|
||||
| {
|
||||
kind: 'switch';
|
||||
variable: string;
|
||||
items: SwitchItemNode[];
|
||||
}
|
||||
/** Legacy `xblox.model.logic.BreakBlock`. */
|
||||
| {
|
||||
kind: 'break';
|
||||
};
|
||||
|
||||
export const blockNodeSchema: z.ZodType<BlockNode> = z.lazy(() =>
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('if'),
|
||||
condition: z.string(),
|
||||
consequent: z.array(blockNodeSchema),
|
||||
elseIfBlocks: z
|
||||
.array(
|
||||
z.object({
|
||||
condition: z.string(),
|
||||
consequent: z.array(blockNodeSchema),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
alternate: z.array(blockNodeSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('runScript'),
|
||||
method: z.string(),
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('for'),
|
||||
initial: z.string(),
|
||||
final: z.string(),
|
||||
comparator: z.string(),
|
||||
modifier: z.string(),
|
||||
items: z.array(blockNodeSchema),
|
||||
ignoreErrors: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('while'),
|
||||
condition: z.string(),
|
||||
items: z.array(blockNodeSchema),
|
||||
loopLimit: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('switch'),
|
||||
variable: z.string(),
|
||||
items: z.array(
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('case'),
|
||||
comparator: z.string(),
|
||||
expression: z.string(),
|
||||
consequent: z.array(blockNodeSchema),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('switchDefault'),
|
||||
consequent: z.array(blockNodeSchema),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('break'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export const blocksFileSchema = z.object({
|
||||
version: z.literal(1),
|
||||
context: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
root: blockNodeSchema,
|
||||
});
|
||||
|
||||
export type BlocksFile = z.infer<typeof blocksFileSchema>;
|
||||
35
packages/xblox/src/types.ts
Normal file
35
packages/xblox/src/types.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Portable types for block graphs — no DOM / Node imports.
|
||||
*/
|
||||
|
||||
import type { EXECUTION_STATE } from './enums.js';
|
||||
|
||||
/** Dojo-SMD–style port / parameter descriptor (minimal). */
|
||||
export interface PortDescriptor {
|
||||
name?: string;
|
||||
type?: string | string[];
|
||||
optional?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Serialized block instance (subset of legacy `simple.json` and Block model). */
|
||||
export interface BlockSnapshot {
|
||||
id: string;
|
||||
declaredClass?: string;
|
||||
name?: string;
|
||||
group?: string;
|
||||
order?: number;
|
||||
enabled?: boolean;
|
||||
serializeMe?: boolean;
|
||||
parentId?: string | null;
|
||||
parent?: unknown;
|
||||
_containsChildrenIds?: string[];
|
||||
method?: string;
|
||||
args?: string;
|
||||
renderBlockIcon?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BlockRuntimeState {
|
||||
executionState: EXECUTION_STATE;
|
||||
}
|
||||
17
packages/xblox/src/uuid.ts
Normal file
17
packages/xblox/src/uuid.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/** RFC4122 v4 — works in modern browsers (`crypto.randomUUID`) and Node 19+. */
|
||||
|
||||
function fallbackRandomUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export function randomUUID(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c?.randomUUID) {
|
||||
return c.randomUUID();
|
||||
}
|
||||
return fallbackRandomUUID();
|
||||
}
|
||||
17
packages/xblox/tests/fixtures/for-sum.json
vendored
Normal file
17
packages/xblox/tests/fixtures/for-sum.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "x": 0 },
|
||||
"root": {
|
||||
"kind": "for",
|
||||
"initial": "0",
|
||||
"final": "3",
|
||||
"comparator": "<",
|
||||
"modifier": "+1",
|
||||
"items": [
|
||||
{
|
||||
"kind": "runScript",
|
||||
"method": "this.x = (this.x || 0) + 1; return this.x;"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
16
packages/xblox/tests/fixtures/if-else-zero.json
vendored
Normal file
16
packages/xblox/tests/fixtures/if-else-zero.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": 0 },
|
||||
"root": {
|
||||
"kind": "if",
|
||||
"condition": "this.n > 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
|
||||
"elseIfBlocks": [
|
||||
{
|
||||
"condition": "this.n < 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'neg';" }]
|
||||
}
|
||||
],
|
||||
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
|
||||
}
|
||||
}
|
||||
16
packages/xblox/tests/fixtures/if-else.json
vendored
Normal file
16
packages/xblox/tests/fixtures/if-else.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": -3 },
|
||||
"root": {
|
||||
"kind": "if",
|
||||
"condition": "this.n > 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
|
||||
"elseIfBlocks": [
|
||||
{
|
||||
"condition": "this.n < 0",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'neg';" }]
|
||||
}
|
||||
],
|
||||
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
|
||||
}
|
||||
}
|
||||
26
packages/xblox/tests/fixtures/switch-case.json
vendored
Normal file
26
packages/xblox/tests/fixtures/switch-case.json
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "mode": 2 },
|
||||
"root": {
|
||||
"kind": "switch",
|
||||
"variable": "mode",
|
||||
"items": [
|
||||
{
|
||||
"kind": "case",
|
||||
"comparator": "==",
|
||||
"expression": "1",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'one';" }]
|
||||
},
|
||||
{
|
||||
"kind": "case",
|
||||
"comparator": "==",
|
||||
"expression": "2",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'two';" }]
|
||||
},
|
||||
{
|
||||
"kind": "switchDefault",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
20
packages/xblox/tests/fixtures/switch-default-only.json
vendored
Normal file
20
packages/xblox/tests/fixtures/switch-default-only.json
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "mode": 99 },
|
||||
"root": {
|
||||
"kind": "switch",
|
||||
"variable": "mode",
|
||||
"items": [
|
||||
{
|
||||
"kind": "case",
|
||||
"comparator": "==",
|
||||
"expression": "1",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'one';" }]
|
||||
},
|
||||
{
|
||||
"kind": "switchDefault",
|
||||
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
packages/xblox/tests/fixtures/while-count.json
vendored
Normal file
14
packages/xblox/tests/fixtures/while-count.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"context": { "n": 0 },
|
||||
"root": {
|
||||
"kind": "while",
|
||||
"condition": "this.n < 3",
|
||||
"items": [
|
||||
{
|
||||
"kind": "runScript",
|
||||
"method": "this.n++; return this.n;"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
17
packages/xblox/tests/test-commons.ts
Normal file
17
packages/xblox/tests/test-commons.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shared paths and helpers for vitest (import from tests only).
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Directory of this file (`tests/`). */
|
||||
export const testsDir = __dirname;
|
||||
|
||||
export const fixturesDir = path.join(testsDir, 'fixtures');
|
||||
|
||||
export function fixture(...parts: string[]): string {
|
||||
return path.join(fixturesDir, ...parts);
|
||||
}
|
||||
38
packages/xblox/tests/unit/cli-run.test.ts
Normal file
38
packages/xblox/tests/unit/cli-run.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runCommand } from '../../src/cli/run-command.js';
|
||||
import { fixture } from '../test-commons.js';
|
||||
|
||||
describe('pm-xblox run (runCommand)', () => {
|
||||
let outDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
outDir = await mkdtemp(path.join(tmpdir(), 'xblox-cli-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs fixture and returns neg', async () => {
|
||||
const { result } = await runCommand({
|
||||
source: fixture('if-else.json'),
|
||||
loglevel: 'error',
|
||||
});
|
||||
expect(result).toBe('neg');
|
||||
});
|
||||
|
||||
it('writes --output JSON', async () => {
|
||||
const outPath = path.join(outDir, 'out.json');
|
||||
await runCommand({
|
||||
source: fixture('if-else-zero.json'),
|
||||
output: outPath,
|
||||
loglevel: 'error',
|
||||
});
|
||||
const text = await readFile(outPath, 'utf8');
|
||||
expect(JSON.parse(text)).toEqual({ result: 'zero' });
|
||||
});
|
||||
});
|
||||
23
packages/xblox/tests/unit/if-else-engine.test.ts
Normal file
23
packages/xblox/tests/unit/if-else-engine.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { executeBlock } from '../../src/engine/execute-block.js';
|
||||
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { fixture } from '../test-commons.js';
|
||||
|
||||
describe('if / else-if / alternate engine', () => {
|
||||
it('evaluates else-if branch (negative n)', async () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
73
packages/xblox/tests/unit/loops-logic.test.ts
Normal file
73
packages/xblox/tests/unit/loops-logic.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { executeBlock } from '../../src/engine/execute-block.js';
|
||||
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
|
||||
import { fixture } from '../test-commons.js';
|
||||
|
||||
describe('ForBlock (legacy xblox.model.loops.ForBlock)', () => {
|
||||
it('runs body while counter < final and applies modifier', async () => {
|
||||
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((ctx as { x: number }).x).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhileBlock (legacy xblox.model.loops.WhileBlock)', () => {
|
||||
it('repeats until condition is false (loopLimit)', async () => {
|
||||
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((ctx as { n: number }).n).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SwitchBlock / CaseBlock / DefaultBlock (legacy logic)', () => {
|
||||
it('runs first matching case', async () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BreakBlock', () => {
|
||||
it('sets switchCtl when executed inside a case consequent', async () => {
|
||||
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';" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const ctx = Object.assign(Object.create(null), file.context) as object;
|
||||
expect(executeBlock(file.root, ctx)).toBe('after');
|
||||
});
|
||||
});
|
||||
28
packages/xblox/tests/unit/run-script-vm.test.ts
Normal file
28
packages/xblox/tests/unit/run-script-vm.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RunScriptBlock } from '../../src/blocks/run-script-block.js';
|
||||
import { runRunScriptBlock, runScriptLegacyVm } from '../../src/runtime/run-script-vm.js';
|
||||
|
||||
describe('runScriptLegacyVm', () => {
|
||||
it('matches legacy Function("{" + script + "}").apply(ctx, args)', () => {
|
||||
const ctx = { x: 2 };
|
||||
const out = runScriptLegacyVm('return this.x * arguments[0];', ctx as object, [21]);
|
||||
expect(out).toBe(42);
|
||||
});
|
||||
|
||||
it('does not let scripts reach real require (sandbox)', () => {
|
||||
expect(() =>
|
||||
runScriptLegacyVm('return typeof require;', {}, [], {
|
||||
vm: { require: false },
|
||||
}),
|
||||
).not.toThrow();
|
||||
const t = runScriptLegacyVm('return typeof require;', {}, [], { vm: { require: false } });
|
||||
expect(t).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RunScriptBlock + runRunScriptBlock', () => {
|
||||
it('runs block.method', () => {
|
||||
const b = new RunScriptBlock({ method: 'return 1 + 1;' });
|
||||
expect(runRunScriptBlock(b, {})).toBe(2);
|
||||
});
|
||||
});
|
||||
19
packages/xblox/tsconfig.json
Normal file
19
packages/xblox/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-in",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"composite": false,
|
||||
"importHelpers": false,
|
||||
"inlineSourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
13
packages/xblox/vitest.config.ts
Normal file
13
packages/xblox/vitest.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/xblox/webpack.config.js
Normal file
32
packages/xblox/webpack.config.js
Normal file
@ -0,0 +1,32 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import webpack from 'webpack';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default {
|
||||
devtool: false,
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
|
||||
],
|
||||
entry: './dist-in/main.js',
|
||||
target: 'node',
|
||||
mode: 'production',
|
||||
module: {
|
||||
rules: [],
|
||||
},
|
||||
optimization: {
|
||||
minimize: false,
|
||||
// Entry is `export * from './index.js'`; without this, production tree-shaking
|
||||
// removes the whole graph and emits an empty bundle.
|
||||
usedExports: false,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.ts'],
|
||||
},
|
||||
output: {
|
||||
filename: 'main_node.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
externals: {},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user