Generate portable capability contexts and checked handler registries
This commit is contained in:
@@ -68,6 +68,7 @@
|
|||||||
checkPhase = ''
|
checkPhase = ''
|
||||||
runHook preCheck
|
runHook preCheck
|
||||||
yarn test
|
yarn test
|
||||||
|
node dist/test/emit-portable-reader.js
|
||||||
runHook postCheck
|
runHook postCheck
|
||||||
'';
|
'';
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
@@ -80,6 +81,8 @@
|
|||||||
cp --reflink=auto --recursive proto "$out/proto"
|
cp --reflink=auto --recursive proto "$out/proto"
|
||||||
cp --reflink=auto --recursive dist "$out/dist"
|
cp --reflink=auto --recursive dist "$out/dist"
|
||||||
cp package.json "$out/package.json"
|
cp package.json "$out/package.json"
|
||||||
|
mkdir -p "$out/share/quixos-protocol/qualification"
|
||||||
|
cp -r dist/portable-reader "$out/share/quixos-protocol/qualification/"
|
||||||
mkdir -p "$out/bin"
|
mkdir -p "$out/bin"
|
||||||
for tool in quixos-codegen-ts quixos-qx; do
|
for tool in quixos-codegen-ts quixos-qx; do
|
||||||
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
|
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
|
||||||
|
|||||||
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { generateTypeScriptBindings } from "./index.js";
|
import { generateTypeScriptBindings, generatePortableTypeScriptBindings } from "./index.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { reactPlatformTypes } from "./react-platform.js";
|
import { reactPlatformTypes } from "./react-platform.js";
|
||||||
import { generateReactBindings } from "./react.js";
|
import { generateReactBindings } from "./react.js";
|
||||||
@@ -11,7 +11,16 @@ const main = async () => {
|
|||||||
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
||||||
const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
|
const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
|
||||||
const contracts = JSON.parse(await readFile(schema, "utf8"));
|
const contracts = JSON.parse(await readFile(schema, "utf8"));
|
||||||
const generated = generateTypeScriptBindings(contracts, revision, config);
|
if (config.portable && config.react)
|
||||||
|
throw new Error("Portable handlers and React component bindings are separate artifacts");
|
||||||
|
if (config.portable && typeof config.portable.target !== "string")
|
||||||
|
throw new Error("Portable bindings require an explicit target");
|
||||||
|
const generated = config.portable
|
||||||
|
? generatePortableTypeScriptBindings(contracts, revision, {
|
||||||
|
...config.portable,
|
||||||
|
...(config.runtimeModule ? { runtimeModule: config.runtimeModule } : {}),
|
||||||
|
})
|
||||||
|
: generateTypeScriptBindings(contracts, revision, config);
|
||||||
await writeFile(output, generated);
|
await writeFile(output, generated);
|
||||||
if (config.react) {
|
if (config.react) {
|
||||||
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
|
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
|
||||||
|
|||||||
@@ -421,3 +421,10 @@ export const generateTypeScriptBindings = (
|
|||||||
`\n },\n});\n`
|
`\n },\n});\n`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
generatePortableTypeScriptBindings,
|
||||||
|
portableValueSchema,
|
||||||
|
PortableBindingError,
|
||||||
|
type PortableBindingOptions,
|
||||||
|
} from "./portable.js";
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { BindingSchema } from "./index.js";
|
||||||
|
import type {
|
||||||
|
ValueType,
|
||||||
|
ObjectExpectation,
|
||||||
|
PackageExport,
|
||||||
|
DependencyPort,
|
||||||
|
ExecutionContract,
|
||||||
|
} from "../capability-model/types.js";
|
||||||
|
import { hexDigest } from "../execution-world/plan.js";
|
||||||
|
|
||||||
|
export class PortableBindingError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly path: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(`${path}: ${message}`);
|
||||||
|
this.name = "PortableBindingError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const q = JSON.stringify;
|
||||||
|
const unit: ValueType = { kind: "builtin", name: "unit" };
|
||||||
|
const string: ValueType = { kind: "scalar", name: "string" };
|
||||||
|
const bool: ValueType = { kind: "scalar", name: "bool" };
|
||||||
|
const isUnit = (t: ValueType) => t.kind === "builtin" && t.name === "unit";
|
||||||
|
const expectation = (e: ObjectExpectation) => ({
|
||||||
|
kind: e.kind,
|
||||||
|
id: e.kind === "atom" ? e.atomId : e.interfaceRevisionId,
|
||||||
|
});
|
||||||
|
/** The protocol IR and replica wire schema intentionally share structure, not reference identity. */
|
||||||
|
export function portableValueSchema(type: ValueType): unknown {
|
||||||
|
switch (type.kind) {
|
||||||
|
case "scalar":
|
||||||
|
return type;
|
||||||
|
case "builtin":
|
||||||
|
if (type.name === "unit") return type;
|
||||||
|
break;
|
||||||
|
case "object-ref":
|
||||||
|
return { kind: type.kind, expectation: expectation(type.expectation) };
|
||||||
|
case "optional":
|
||||||
|
case "list":
|
||||||
|
return { kind: type.kind, value: portableValueSchema(type.value) };
|
||||||
|
case "record":
|
||||||
|
return {
|
||||||
|
kind: type.kind,
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(type.fields).map(([key, value]) => [key, portableValueSchema(value)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new PortableBindingError(
|
||||||
|
type.kind,
|
||||||
|
"Unsupported portable value; use a supported record/reference contract (native edits and generic adapters have separate tasks)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function tsType(type: ValueType): string {
|
||||||
|
portableValueSchema(type);
|
||||||
|
switch (type.kind) {
|
||||||
|
case "scalar":
|
||||||
|
return type.name === "bool"
|
||||||
|
? "boolean"
|
||||||
|
: type.name === "string"
|
||||||
|
? "string"
|
||||||
|
: type.name === "bytes"
|
||||||
|
? "Uint8Array"
|
||||||
|
: ["int64", "uint64"].includes(type.name)
|
||||||
|
? "bigint"
|
||||||
|
: "number";
|
||||||
|
case "builtin":
|
||||||
|
return "null";
|
||||||
|
case "object-ref": {
|
||||||
|
const e = expectation(type.expectation);
|
||||||
|
return `ManagedReference<${q(e.kind)}, ${q(e.id)}>`;
|
||||||
|
}
|
||||||
|
case "optional":
|
||||||
|
return `(${tsType(type.value)} | null)`;
|
||||||
|
case "list":
|
||||||
|
return `ReadonlyArray<${tsType(type.value)}>`;
|
||||||
|
case "record":
|
||||||
|
return `{ ${Object.entries(type.fields)
|
||||||
|
.map(([key, value]) => `readonly ${q(key)}: ${tsType(value)}`)
|
||||||
|
.join("; ")} }`;
|
||||||
|
default:
|
||||||
|
throw new PortableBindingError(type.kind, "Unsupported type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface Method {
|
||||||
|
name: string;
|
||||||
|
portId: string;
|
||||||
|
portName: string;
|
||||||
|
action: { kind: string; primitive?: string; operationId?: string };
|
||||||
|
input: ValueType;
|
||||||
|
output: ValueType;
|
||||||
|
keyed: boolean;
|
||||||
|
}
|
||||||
|
function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPort, target: string): Method[] {
|
||||||
|
const req = port.requirement;
|
||||||
|
const path = `${entry.displayName}.ports.${port.displayName}`;
|
||||||
|
const method = (
|
||||||
|
name: string,
|
||||||
|
action: Method["action"],
|
||||||
|
input: ValueType,
|
||||||
|
output: ValueType,
|
||||||
|
keyed = false,
|
||||||
|
): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed });
|
||||||
|
const writable = () => {
|
||||||
|
if (entry.execution?.kind !== "transaction")
|
||||||
|
throw new PortableBindingError(path, "Snapshot context cannot expose mutation ports");
|
||||||
|
};
|
||||||
|
switch (req.kind) {
|
||||||
|
case "creation":
|
||||||
|
writable();
|
||||||
|
return [
|
||||||
|
method(
|
||||||
|
"create",
|
||||||
|
{ kind: "creation" },
|
||||||
|
req.inputType,
|
||||||
|
{ kind: "object-ref", expectation: { kind: "atom", atomId: req.atomId } },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case "state":
|
||||||
|
return req.primitives.map((primitive) => {
|
||||||
|
if (primitive === "read") return method("get", { kind: "state", primitive }, unit, req.valueType);
|
||||||
|
if (primitive === "write") {
|
||||||
|
writable();
|
||||||
|
return method("set", { kind: "state", primitive }, req.valueType, unit);
|
||||||
|
}
|
||||||
|
throw new PortableBindingError(path, `State ${primitive} is not supported by this portable adapter yet`);
|
||||||
|
});
|
||||||
|
case "edge": {
|
||||||
|
const ref: ValueType = { kind: "object-ref", expectation: req.target };
|
||||||
|
const item: ValueType = { kind: "record", fields: { entry: string, target: ref } };
|
||||||
|
return req.primitives.map((primitive) => {
|
||||||
|
if (primitive === "resolve")
|
||||||
|
return method("resolve", { kind: "edge", primitive }, unit, { kind: "list", value: item });
|
||||||
|
writable();
|
||||||
|
if (primitive === "connect") return method("connect", { kind: "edge", primitive }, ref, string, true);
|
||||||
|
if (primitive === "disconnect") return method("disconnect", { kind: "edge", primitive }, string, bool);
|
||||||
|
throw new PortableBindingError(path, `Edge ${primitive} is not supported by this portable adapter yet`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "interface": {
|
||||||
|
const iface = schema.interfaces.find((i) => i.revisionId === req.interfaceRevisionId);
|
||||||
|
if (!iface) throw new PortableBindingError(path, `Missing interface ${req.interfaceRevisionId}`);
|
||||||
|
const operations = iface.members.flatMap((member) => member.operations.map((op) => ({ member, op })));
|
||||||
|
if (req.operations?.some((id) => !operations.some(({ op }) => op.id === id)))
|
||||||
|
throw new PortableBindingError(path, "Unknown selected interface operation");
|
||||||
|
return operations
|
||||||
|
.filter(({ op }) => !req.operations || req.operations.includes(op.id))
|
||||||
|
.map(({ member, op }) => {
|
||||||
|
const execution: ExecutionContract = op.execution ?? { kind: "remote" };
|
||||||
|
if (
|
||||||
|
execution.kind === "remote" ||
|
||||||
|
!execution.targets.includes(target) ||
|
||||||
|
op.mode !== "call" ||
|
||||||
|
op.scope === "class"
|
||||||
|
)
|
||||||
|
throw new PortableBindingError(
|
||||||
|
`${path}.${member.displayName}.${op.displayName}`,
|
||||||
|
`Selected operation is not an object call available in portable target ${target}; select a portable subset or move this handler to remote execution`,
|
||||||
|
);
|
||||||
|
if (execution.kind === "transaction") writable();
|
||||||
|
return method(
|
||||||
|
`${member.displayName}.${op.displayName}`,
|
||||||
|
{ kind: "interface", operationId: op.id },
|
||||||
|
op.inputType,
|
||||||
|
op.outputType,
|
||||||
|
execution.kind === "transaction",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new PortableBindingError(path, `Unsupported ${req.kind} port in portable bindings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface PortableBindingOptions {
|
||||||
|
target: string;
|
||||||
|
runtimeModule?: string;
|
||||||
|
}
|
||||||
|
/** Generate one target's handlers. The same source signatures are used in browser/server builds. */
|
||||||
|
export function generatePortableTypeScriptBindings(
|
||||||
|
schema: BindingSchema,
|
||||||
|
revision: string,
|
||||||
|
options: PortableBindingOptions,
|
||||||
|
): string {
|
||||||
|
const pkg = schema.packages.find((p) => p.revisionId === revision);
|
||||||
|
if (!pkg) throw new PortableBindingError(revision, "Missing package");
|
||||||
|
const entries = pkg.exports.filter(
|
||||||
|
(e) => e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target),
|
||||||
|
);
|
||||||
|
const names = new Set<string>();
|
||||||
|
const contexts: string[] = [],
|
||||||
|
implementations: string[] = [],
|
||||||
|
adapters: string[] = [],
|
||||||
|
descriptors: unknown[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.application || entry.kind === "constructor")
|
||||||
|
throw new PortableBindingError(
|
||||||
|
entry.id,
|
||||||
|
"Portable generic adapters/legacy constructors are not implemented; declare an ordinary factory",
|
||||||
|
);
|
||||||
|
if (names.has(entry.displayName)) throw new PortableBindingError(entry.id, "Duplicate handler name");
|
||||||
|
names.add(entry.displayName);
|
||||||
|
const portNames = new Set<string>();
|
||||||
|
const all: Method[] = [],
|
||||||
|
facets: string[] = [];
|
||||||
|
for (const port of entry.dependencyPorts) {
|
||||||
|
if (portNames.has(port.displayName))
|
||||||
|
throw new PortableBindingError(entry.id, `Duplicate port name ${port.displayName}`);
|
||||||
|
portNames.add(port.displayName);
|
||||||
|
const selected = methods(schema, entry, port, options.target);
|
||||||
|
if (new Set(selected.map((m) => m.name)).size !== selected.length)
|
||||||
|
throw new PortableBindingError(port.id, "Duplicate method name");
|
||||||
|
all.push(...selected);
|
||||||
|
facets.push(
|
||||||
|
`readonly ${q(port.displayName)}: { ${selected.map((m) => `readonly ${q(m.name)}: (${[...(isUnit(m.input) ? [] : [`input: ${tsType(m.input)}`]), ...(m.keyed ? ["options: MutationKey"] : [])].join(", ")}) => Promise<${tsType(m.output)}>`).join("; ")} }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const receiver =
|
||||||
|
entry.kind === "function"
|
||||||
|
? null
|
||||||
|
: entry.receiverRequirement.kind === "exact-atom"
|
||||||
|
? { kind: "atom", id: entry.receiverRequirement.atomId }
|
||||||
|
: "object";
|
||||||
|
const receiverType =
|
||||||
|
receiver === null
|
||||||
|
? "null"
|
||||||
|
: receiver === "object"
|
||||||
|
? "ExactReference"
|
||||||
|
: `ManagedReference<"atom", ${q(receiver.id)}>`;
|
||||||
|
contexts.push(
|
||||||
|
`readonly ${q(entry.displayName)}: { readonly effect: ${q(entry.execution!.kind)}; readonly input: ${tsType(entry.inputType)}; readonly receiver: ${receiverType}; readonly ports: { ${facets.join("; ")} } }`,
|
||||||
|
);
|
||||||
|
const output = tsType(entry.outputType);
|
||||||
|
implementations.push(
|
||||||
|
`readonly ${q(entry.displayName)}: (context: Contexts[${q(entry.displayName)}]) => ${output} | Promise<${output}>`,
|
||||||
|
);
|
||||||
|
adapters.push(
|
||||||
|
`${q(entry.id)}: context => installed[${q(entry.displayName)}](context as Contexts[${q(entry.displayName)}])`,
|
||||||
|
);
|
||||||
|
descriptors.push({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.displayName,
|
||||||
|
effect: entry.execution!.kind,
|
||||||
|
receiver,
|
||||||
|
input: portableValueSchema(entry.inputType),
|
||||||
|
output: portableValueSchema(entry.outputType),
|
||||||
|
methods: all.map((m) => ({ ...m, input: portableValueSchema(m.input), output: portableValueSchema(m.output) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `// Generated portable bindings. Regenerate from the checked capability schema; do not edit.\nimport { definePortableRegistry, type PortableExport, type ManagedReference, type ExactReference, type MutationKey } from ${q(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const bindingDigest = ${q(hexDigest(schema))};\nexport const packageRevision = ${q(revision)};\nexport const target = ${q(options.target)};\nexport type Contexts = { ${contexts.join(";\n")} };\nexport type PortableImplementation = { ${implementations.join(";\n")} };\nexport const declarations = ${JSON.stringify(descriptors, null, 2)} as const satisfies readonly PortableExport[];\nexport function createRegistry(handlers: PortableImplementation) { const installed = { ...handlers }; return definePortableRegistry(declarations, { ${adapters.join(",\n")} }); }\n`;
|
||||||
|
}
|
||||||
@@ -89,3 +89,17 @@ The service control vocabulary includes readiness, cancellation, and shutdown;
|
|||||||
transport framing and generated adapters are LF-05/LF-08. Portable modules and
|
transport framing and generated adapters are LF-05/LF-08. Portable modules and
|
||||||
services implement the same declared operation contracts. ABI compatibility is
|
services implement the same declared operation contracts. ABI compatibility is
|
||||||
not a statement about package trust, co-location permission, or multi-user isolation.
|
not a statement about package trust, co-location permission, or multi-user isolation.
|
||||||
|
|
||||||
|
Portable package bindings are generated with `quixos-codegen-ts` options
|
||||||
|
`{"portable":{"target":"browser"}}` (or `server`). They expose `Contexts`,
|
||||||
|
`PortableImplementation`, checked codec declarations, and `createRegistry`.
|
||||||
|
Handlers import the shared replica core, never Replicache. Stable mutation keys
|
||||||
|
are mandatory for creation and selected nested transaction calls. Snapshot
|
||||||
|
contexts expose only their declared read methods; runtime frame checks remain
|
||||||
|
mandatory even when the TypeScript source passed compilation.
|
||||||
|
|
||||||
|
The maintained reader source in `tools/local-first/reader/portable.ts` typechecks
|
||||||
|
against both generated targets without casts. The root
|
||||||
|
`camino-portable-bindings` qualification compares Node, Window, Worker and
|
||||||
|
serialized-port behavior. Its simulated channel tests adapters, not installed
|
||||||
|
operation acquisition or process isolation; those require the coordinator.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Qualification artifact emitted after tests, never a production fixture dependency. */
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { loadReaderWorkspace } from "./reader-fixture.js";
|
||||||
|
import { compileExecutionPlan } from "../src/execution-world/plan.js";
|
||||||
|
import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js";
|
||||||
|
const plan = compileExecutionPlan(await loadReaderWorkspace());
|
||||||
|
await mkdir("dist/portable-reader", { recursive: true });
|
||||||
|
for (const target of ["browser", "server"])
|
||||||
|
await writeFile(
|
||||||
|
`dist/portable-reader/${target}.ts`,
|
||||||
|
generatePortableTypeScriptBindings(plan.definition.bindingSchema, "package:reader-runtime@1", { target }),
|
||||||
|
);
|
||||||
|
await writeFile("dist/portable-reader/plan.json", JSON.stringify(plan));
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js";
|
||||||
|
import { compileExecutionPlan } from "../src/execution-world/plan.js";
|
||||||
|
import { loadReaderWorkspace } from "./reader-fixture.js";
|
||||||
|
|
||||||
|
test("reader portable bindings have selected facets, creation inputs and stable keyed calls", async () => {
|
||||||
|
const schema = compileExecutionPlan(await loadReaderWorkspace()).definition.bindingSchema;
|
||||||
|
const source = generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" });
|
||||||
|
assert.match(source, /readonly "sourceRevision.get": \(\) => Promise<string>/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/readonly "highlights.connect": \(input: ManagedReference<"atom", "atom:reader:highlight">, options: MutationKey\)/,
|
||||||
|
);
|
||||||
|
assert.match(source, /readonly "createDocument"/);
|
||||||
|
assert.match(source, /readonly "create":/);
|
||||||
|
assert.match(source, /readonly "comments": ReadonlyArray/);
|
||||||
|
assert.doesNotMatch(source, /"highlights.resolve"|context\.conform|objectId|"live"/);
|
||||||
|
assert.equal(source, generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" }));
|
||||||
|
const server = generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "server" });
|
||||||
|
assert.equal(source.replace('target = "browser"', 'target = "server"'), server);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation rejects remote dependencies, wrong effects and unsupported shapes explicitly", async () => {
|
||||||
|
const schema = compileExecutionPlan(await loadReaderWorkspace()).definition.bindingSchema;
|
||||||
|
const generate = () => generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" });
|
||||||
|
const entry = schema.packages[0]!.exports.find((e) => e.displayName === "addHighlight")!;
|
||||||
|
entry.execution = { kind: "snapshot", targets: ["browser"] };
|
||||||
|
assert.throws(generate, /Snapshot context cannot expose mutation ports/);
|
||||||
|
entry.execution = { kind: "transaction", targets: ["browser"] };
|
||||||
|
const iface = schema.interfaces.find((i) => i.revisionId === "interface:reader:document-data@1")!;
|
||||||
|
const getter = iface.members[0]!.operations[0]!;
|
||||||
|
getter.execution = { kind: "remote" };
|
||||||
|
assert.throws(generate, /not an object call available in portable target browser/);
|
||||||
|
getter.execution = { kind: "snapshot", targets: ["browser", "server"] };
|
||||||
|
entry.dependencyPorts[0]!.requirement = { kind: "reflection", selectors: ["active"] };
|
||||||
|
assert.throws(generate, /Unsupported reflection port/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user