@githolon/dsl 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart/CHANGELOG.md +5 -0
- package/dart/lib/nomos_headless.dart +19 -0
- package/dart/lib/src/node_runner_transport.dart +125 -0
- package/dart/lib/src/test_harness.dart +235 -0
- package/dart/pubspec.yaml +1 -1
- package/dart/test/node_runner_transport_test.dart +74 -0
- package/dart/test/test_harness_test.dart +70 -0
- package/package.json +1 -1
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 0.28.0
|
|
2
|
+
|
|
3
|
+
- `NomosTestHarness` (`nomos_headless.dart`): ephemeral real-law fixtures — one call births a throwaway cloud workspace, deploys compiled `*.deploy.json` law, and hands out independent client replicas (`connect(clientId:)`) over the node runner. Identity defaults to the `githolon login --agent` session (`~/.holon/credentials.json`). Live-proven: birth → deploy → write on one replica → converge + typed read on another, 14s end-to-end.
|
|
4
|
+
- HEADLESS TRANSPORT (`package:nomos_client/nomos_headless.dart`): `NodeRunnerTransport` — a `BridgeTransport` over the `@githolon/client` node runner (`runner/node-entry.mjs`) as a plain child process (NDJSON stdio). `flutter test` / `dart test` / CI drive the REAL githolon (byte-identical wasm, real ledger, real gate) through `NomosBridge` + the generated typed clients — no Flutter plugins, no WebView, no assets. Requires `node` on PATH + a resolvable `@githolon/client` (auto-resolved from the app's node_modules, or pass `entry:`). Proven end-to-end: connect → offerDirective → sync(admit) → typed query, and the same test green under both `dart test` and `flutter test`.
|
|
5
|
+
|
|
1
6
|
## 0.27.0
|
|
2
7
|
|
|
3
8
|
- TYPED SNAPSHOT RECOVERY (corrupt local custody): `NomosBridge.connect(recover: true)` recovers IN-PLACE from a corrupt restoreFrom snapshot — salvages local-only authored writes (via the fallible `intents_above`, which stops at a missing-ancestor gap) + the DLQ, refolds FRESH from cloud, re-offers the salvaged work (deduped), re-queues the DLQ. The report rides `bridge.lastRecovery` = `{salvagedWrites, requeuedDeadLetters, unsalvageable}` — a write the current law refuses lands in `unsalvageable` + the DLQ (surfaced, never dropped). Also `holon.salvage()` / `holon.absorbSalvage()`. Closes the restoreFrom-leaves-read-model-empty case.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// The HEADLESS transport library — `dart:io` only, so it lives outside the main
|
|
2
|
+
/// `package:nomos_client/nomos_client.dart` barrel (which stays web-safe).
|
|
3
|
+
///
|
|
4
|
+
/// ```dart
|
|
5
|
+
/// import 'package:nomos_client/nomos_client.dart';
|
|
6
|
+
/// import 'package:nomos_client/nomos_headless.dart';
|
|
7
|
+
///
|
|
8
|
+
/// final transport = await NodeRunnerTransport.start(resolveFrom: 'path/to/app');
|
|
9
|
+
/// final bridge = NomosBridge(transport);
|
|
10
|
+
/// await bridge.ready;
|
|
11
|
+
/// await bridge.connect(cloud: ..., workspace: ..., clientId: 'test-runner');
|
|
12
|
+
/// ```
|
|
13
|
+
///
|
|
14
|
+
/// The runner child process is the SAME `@githolon/client` every peer runs; the
|
|
15
|
+
/// generated Dart clients drive it through [NomosBridge] exactly as in the app.
|
|
16
|
+
library;
|
|
17
|
+
|
|
18
|
+
export 'src/node_runner_transport.dart';
|
|
19
|
+
export 'src/test_harness.dart';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// THE HEADLESS (NODE) TRANSPORT — a [BridgeTransport] over the `@githolon/client` node runner
|
|
2
|
+
// (`runner/node-entry.mjs`), spawned as a plain child process speaking the ONE bridge protocol as
|
|
3
|
+
// newline-delimited JSON on stdio. This is what lets `dart test` / `flutter test` / CI drive the REAL
|
|
4
|
+
// githolon (the byte-identical wasm, the real ledger, the real gate) through [NomosBridge] and the
|
|
5
|
+
// generated typed clients — no Flutter plugins, no WebView, no assets. The only environment requirement
|
|
6
|
+
// is `node` on PATH plus a resolvable `@githolon/client` (any app that compiles domains already has both).
|
|
7
|
+
//
|
|
8
|
+
// This file is a PURE RELAY: no orchestration, no op knowledge — the protocol implementation lives in
|
|
9
|
+
// createWebviewRunner (shared with the WebView path) and the orchestration in index.mjs, same as every peer.
|
|
10
|
+
import 'dart:async';
|
|
11
|
+
import 'dart:convert';
|
|
12
|
+
import 'dart:io';
|
|
13
|
+
|
|
14
|
+
import 'webview_bridge.dart' show BridgeTransport;
|
|
15
|
+
|
|
16
|
+
class NodeRunnerTransport implements BridgeTransport {
|
|
17
|
+
final Process _proc;
|
|
18
|
+
final StreamController<Map<String, Object?>> _incoming =
|
|
19
|
+
StreamController<Map<String, Object?>>.broadcast();
|
|
20
|
+
final void Function(String line)? _onDiagnostic;
|
|
21
|
+
bool _disposed = false;
|
|
22
|
+
|
|
23
|
+
NodeRunnerTransport._(this._proc, this._onDiagnostic) {
|
|
24
|
+
_proc.stdout
|
|
25
|
+
.transform(utf8.decoder)
|
|
26
|
+
.transform(const LineSplitter())
|
|
27
|
+
.listen((line) {
|
|
28
|
+
final s = line.trim();
|
|
29
|
+
if (s.isEmpty) return;
|
|
30
|
+
Object? decoded;
|
|
31
|
+
try {
|
|
32
|
+
decoded = jsonDecode(s);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
_diag('[nomos-node-runner stdout] $line');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (decoded is Map && !_incoming.isClosed) {
|
|
38
|
+
_incoming.add(decoded.cast<String, Object?>());
|
|
39
|
+
}
|
|
40
|
+
}, onDone: () {
|
|
41
|
+
if (!_disposed && !_incoming.isClosed) {
|
|
42
|
+
_incoming.addError(
|
|
43
|
+
StateError('the nomos node runner exited unexpectedly'));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
_proc.stderr
|
|
47
|
+
.transform(utf8.decoder)
|
|
48
|
+
.transform(const LineSplitter())
|
|
49
|
+
.listen((line) => _diag('[nomos-node-runner] $line'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void _diag(String line) {
|
|
53
|
+
final f = _onDiagnostic;
|
|
54
|
+
if (f != null) {
|
|
55
|
+
f(line);
|
|
56
|
+
} else {
|
|
57
|
+
stderr.writeln(line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Spawn the node runner and return the connected transport. Wrap it in a
|
|
62
|
+
/// [NomosBridge] and `await bridge.ready` (the runner posts `{event:'ready'}`
|
|
63
|
+
/// on boot), then `connect(...)` as any other transport.
|
|
64
|
+
///
|
|
65
|
+
/// [entry] — path to `node-entry.mjs`. When omitted it is resolved with
|
|
66
|
+
/// `require.resolve('@githolon/client/runner/node-entry.mjs')` from
|
|
67
|
+
/// [resolveFrom] (default: the current directory), i.e. the consuming app's
|
|
68
|
+
/// own `node_modules`.
|
|
69
|
+
static Future<NodeRunnerTransport> start({
|
|
70
|
+
String? entry,
|
|
71
|
+
String? resolveFrom,
|
|
72
|
+
String node = 'node',
|
|
73
|
+
Map<String, String>? environment,
|
|
74
|
+
void Function(String line)? onDiagnostic,
|
|
75
|
+
}) async {
|
|
76
|
+
final entryPath = entry ??
|
|
77
|
+
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
78
|
+
final proc = await Process.start(
|
|
79
|
+
node,
|
|
80
|
+
[entryPath],
|
|
81
|
+
environment: environment,
|
|
82
|
+
workingDirectory: resolveFrom,
|
|
83
|
+
);
|
|
84
|
+
return NodeRunnerTransport._(proc, onDiagnostic);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static Future<String> _resolveEntry(String node, String from) async {
|
|
88
|
+
final r = await Process.run(
|
|
89
|
+
node,
|
|
90
|
+
['-p', "require.resolve('@githolon/client/runner/node-entry.mjs')"],
|
|
91
|
+
workingDirectory: from,
|
|
92
|
+
);
|
|
93
|
+
final out = (r.stdout as String).trim();
|
|
94
|
+
if (r.exitCode != 0 || out.isEmpty) {
|
|
95
|
+
throw StateError(
|
|
96
|
+
'cannot resolve @githolon/client/runner/node-entry.mjs from $from — '
|
|
97
|
+
'is @githolon/client installed (npm install)? stderr: ${r.stderr}');
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
void send(Map<String, Object?> message) {
|
|
107
|
+
if (_disposed) throw StateError('NodeRunnerTransport is disposed');
|
|
108
|
+
_proc.stdin.writeln(jsonEncode(message));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Tear down: stop the runner process and close the message stream.
|
|
112
|
+
Future<void> dispose() async {
|
|
113
|
+
if (_disposed) return;
|
|
114
|
+
_disposed = true;
|
|
115
|
+
try {
|
|
116
|
+
await _proc.stdin.close();
|
|
117
|
+
} catch (_) {}
|
|
118
|
+
_proc.kill(ProcessSignal.sigterm);
|
|
119
|
+
await _proc.exitCode.timeout(const Duration(seconds: 5), onTimeout: () {
|
|
120
|
+
_proc.kill(ProcessSignal.sigkill);
|
|
121
|
+
return -9;
|
|
122
|
+
});
|
|
123
|
+
await _incoming.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// NOMOS TEST HARNESS — ephemeral real-law fixtures for headless app tests. One call births a throwaway
|
|
2
|
+
// workspace on the cloud, deploys the given compiled domain packages (`build/<name>.deploy.json`), and hands
|
|
3
|
+
// out [NomosBridge]s over [NodeRunnerTransport] — the REAL githolon (byte-identical wasm, real ledger, real
|
|
4
|
+
// gate) driven by the same generated Dart clients the app UI uses, under `flutter test` / `dart test` / CI.
|
|
5
|
+
//
|
|
6
|
+
// ```dart
|
|
7
|
+
// final harness = await NomosTestHarness.ephemeral(
|
|
8
|
+
// deployJsonPaths: ['build/guestbook.deploy.json'],
|
|
9
|
+
// );
|
|
10
|
+
// final bridge = await harness.connect(clientId: 'test-runner');
|
|
11
|
+
// final app = GuestbookClient(bridge); // the generated client — same one the app uses
|
|
12
|
+
// ...
|
|
13
|
+
// await harness.dispose();
|
|
14
|
+
// ```
|
|
15
|
+
//
|
|
16
|
+
// Identity: a birth needs a principal. Pass [principal]/[authToken] explicitly, or the harness falls back to
|
|
17
|
+
// the `githolon` CLI session (`~/.holon/credentials.json`, minted by `npx githolon login --agent`) — the same
|
|
18
|
+
// self-onboarded verified identity the compile/deploy tooling uses. The token rides `x-nomos-auth` as proof;
|
|
19
|
+
// its `sub` claim rides `x-nomos-principal` (the transitional bare claim the birth lane requires until kernel
|
|
20
|
+
// birth-verify lands).
|
|
21
|
+
//
|
|
22
|
+
// The harness is HOST-LANE plumbing only (create + deploy are the open, kernel-gated cloud endpoints — the
|
|
23
|
+
// same calls `githolon ws create` / `githolon deploy` make); everything after rides the one bridge protocol.
|
|
24
|
+
// Workspaces are throwaway: there is no open destroy lane (reclaim is law-gated), so `dispose()` tears down
|
|
25
|
+
// the local transports and leaves the ledger — name them uniquely (the default) and let them age out.
|
|
26
|
+
import 'dart:convert';
|
|
27
|
+
import 'dart:io';
|
|
28
|
+
import 'dart:math';
|
|
29
|
+
|
|
30
|
+
import 'node_runner_transport.dart';
|
|
31
|
+
import 'webview_bridge.dart' show NomosBridge;
|
|
32
|
+
|
|
33
|
+
class NomosTestHarnessException implements Exception {
|
|
34
|
+
final String message;
|
|
35
|
+
NomosTestHarnessException(this.message);
|
|
36
|
+
@override
|
|
37
|
+
String toString() => 'NomosTestHarnessException: $message';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class NomosTestHarness {
|
|
41
|
+
/// The cloud base URL the harness (and every bridge it connects) talks to.
|
|
42
|
+
final String cloud;
|
|
43
|
+
|
|
44
|
+
/// The ephemeral workspace name this harness birthed.
|
|
45
|
+
final String workspace;
|
|
46
|
+
|
|
47
|
+
final String? _principal;
|
|
48
|
+
final String? _authToken;
|
|
49
|
+
final String? _nodeEntry;
|
|
50
|
+
final String? _resolveFrom;
|
|
51
|
+
final void Function(String line)? _onDiagnostic;
|
|
52
|
+
final List<NodeRunnerTransport> _transports = [];
|
|
53
|
+
bool _disposed = false;
|
|
54
|
+
|
|
55
|
+
NomosTestHarness._(this.cloud, this.workspace, this._principal,
|
|
56
|
+
this._authToken, this._nodeEntry, this._resolveFrom, this._onDiagnostic);
|
|
57
|
+
|
|
58
|
+
/// Birth a throwaway workspace and deploy the given compiled domain packages into it.
|
|
59
|
+
///
|
|
60
|
+
/// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (deployed in order;
|
|
61
|
+
/// platform/framework law first when one domain's law depends on another's).
|
|
62
|
+
/// [workspace] — override the minted `test-<hex>` name (e.g. to reuse a workspace across a suite).
|
|
63
|
+
/// [principal]/[authToken] — the identity the workspace is born for; defaults to the `githolon` CLI
|
|
64
|
+
/// session (`~/.holon/credentials.json`).
|
|
65
|
+
/// [nodeEntry]/[resolveFrom] — forwarded to [NodeRunnerTransport.start].
|
|
66
|
+
static Future<NomosTestHarness> ephemeral({
|
|
67
|
+
List<String> deployJsonPaths = const [],
|
|
68
|
+
String cloud = 'https://nomos.captainapp.co.uk',
|
|
69
|
+
String? workspace,
|
|
70
|
+
String? principal,
|
|
71
|
+
String? authToken,
|
|
72
|
+
String? nodeEntry,
|
|
73
|
+
String? resolveFrom,
|
|
74
|
+
void Function(String line)? onDiagnostic,
|
|
75
|
+
}) async {
|
|
76
|
+
final id = _identity(principal, authToken);
|
|
77
|
+
final ws = workspace ?? 'test-${_hex(12)}';
|
|
78
|
+
final harness = NomosTestHarness._(_stripSlash(cloud), ws, id.principal,
|
|
79
|
+
id.authToken, nodeEntry, resolveFrom, onDiagnostic);
|
|
80
|
+
await harness._create();
|
|
81
|
+
for (final p in deployJsonPaths) {
|
|
82
|
+
await harness.deployLaw(p);
|
|
83
|
+
}
|
|
84
|
+
return harness;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static String _brief(String s) => s.length > 600 ? '${s.substring(0, 600)}…[+${s.length - 600} chars]' : s;
|
|
88
|
+
|
|
89
|
+
/// Deploy one more compiled package (`<name>.deploy.json`) into the workspace — e.g. a law evolve mid-test.
|
|
90
|
+
Future<void> deployLaw(String deployJsonPath) async {
|
|
91
|
+
final f = File(deployJsonPath);
|
|
92
|
+
if (!f.existsSync()) {
|
|
93
|
+
throw NomosTestHarnessException(
|
|
94
|
+
'deploy json not found: $deployJsonPath — run `npx githolon compile` first');
|
|
95
|
+
}
|
|
96
|
+
final r = await _post('/v2/workspaces/$workspace/domains',
|
|
97
|
+
body: await f.readAsString(), contentType: 'application/json');
|
|
98
|
+
if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
|
|
99
|
+
throw NomosTestHarnessException(
|
|
100
|
+
'deploy of $deployJsonPath to $workspace refused (${r.$1}): ${_brief(r.$3)}');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Spawn a fresh runner + bridge connected to the harness workspace. Each call is an independent
|
|
105
|
+
/// client replica (own clientId → own session branch), so multi-client convergence is testable.
|
|
106
|
+
Future<NomosBridge> connect({
|
|
107
|
+
String clientId = 'test-runner',
|
|
108
|
+
String? authToken,
|
|
109
|
+
String? authorSecret,
|
|
110
|
+
bool allowUnborn = false,
|
|
111
|
+
}) async {
|
|
112
|
+
if (_disposed) throw StateError('harness is disposed');
|
|
113
|
+
final transport = await NodeRunnerTransport.start(
|
|
114
|
+
entry: _nodeEntry,
|
|
115
|
+
resolveFrom: _resolveFrom,
|
|
116
|
+
onDiagnostic: _onDiagnostic,
|
|
117
|
+
);
|
|
118
|
+
_transports.add(transport);
|
|
119
|
+
final bridge = NomosBridge(transport);
|
|
120
|
+
await bridge.ready.timeout(const Duration(seconds: 30));
|
|
121
|
+
await bridge.connect(
|
|
122
|
+
cloud: cloud,
|
|
123
|
+
workspace: workspace,
|
|
124
|
+
clientId: clientId,
|
|
125
|
+
authToken: authToken ?? _authToken,
|
|
126
|
+
authorSecret: authorSecret,
|
|
127
|
+
allowUnborn: allowUnborn,
|
|
128
|
+
);
|
|
129
|
+
return bridge;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Tear down every runner this harness spawned. The throwaway workspace's ledger stays on the cloud
|
|
133
|
+
/// (there is no open destroy lane — reclaim is law-gated); its unique name keeps it inert.
|
|
134
|
+
Future<void> dispose() async {
|
|
135
|
+
if (_disposed) return;
|
|
136
|
+
_disposed = true;
|
|
137
|
+
for (final t in _transports) {
|
|
138
|
+
await t.dispose();
|
|
139
|
+
}
|
|
140
|
+
_transports.clear();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── host-lane plumbing ──
|
|
144
|
+
|
|
145
|
+
Future<void> _create() async {
|
|
146
|
+
final r = await _post('/v2/workspaces/$workspace');
|
|
147
|
+
if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
|
|
148
|
+
throw NomosTestHarnessException(
|
|
149
|
+
'workspace birth for $workspace refused (${r.$1}): ${_brief(r.$3)} — '
|
|
150
|
+
'a birth needs a principal (npx githolon login --agent, or pass principal:/authToken:)');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Future<(int, Map<String, Object?>, String)> _post(String path,
|
|
155
|
+
{String? body, String? contentType}) async {
|
|
156
|
+
final client = HttpClient();
|
|
157
|
+
try {
|
|
158
|
+
final req = await client.postUrl(Uri.parse('$cloud$path'));
|
|
159
|
+
if (_authToken != null) req.headers.set('x-nomos-auth', _authToken);
|
|
160
|
+
if (_principal != null) req.headers.set('x-nomos-principal', _principal);
|
|
161
|
+
if (contentType != null) {
|
|
162
|
+
req.headers.contentType = ContentType.parse('$contentType; charset=utf-8');
|
|
163
|
+
}
|
|
164
|
+
if (body != null) req.add(utf8.encode(body));
|
|
165
|
+
final res = await req.close();
|
|
166
|
+
final text = await res.transform(utf8.decoder).join();
|
|
167
|
+
Map<String, Object?> decoded = const {};
|
|
168
|
+
try {
|
|
169
|
+
final d = jsonDecode(text);
|
|
170
|
+
if (d is Map) decoded = d.cast<String, Object?>();
|
|
171
|
+
} catch (_) {}
|
|
172
|
+
return (res.statusCode, decoded, text);
|
|
173
|
+
} finally {
|
|
174
|
+
client.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static ({String? principal, String? authToken}) _identity(
|
|
179
|
+
String? principal, String? authToken) {
|
|
180
|
+
var p = principal;
|
|
181
|
+
var t = authToken;
|
|
182
|
+
if (p == null && t == null) {
|
|
183
|
+
final creds = _holonCreds();
|
|
184
|
+
p = creds.principal;
|
|
185
|
+
t = creds.authToken;
|
|
186
|
+
}
|
|
187
|
+
// the token's own `sub` claim is the transitional bare principal claim the birth lane requires.
|
|
188
|
+
p ??= t != null ? _jwtSub(t) : null;
|
|
189
|
+
if (p == null && t == null) {
|
|
190
|
+
throw NomosTestHarnessException(
|
|
191
|
+
'no identity for the workspace birth — run `npx githolon login --agent` '
|
|
192
|
+
'(the harness reads ~/.holon/credentials.json) or pass principal:/authToken:');
|
|
193
|
+
}
|
|
194
|
+
return (principal: p, authToken: t);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
static ({String? principal, String? authToken}) _holonCreds() {
|
|
198
|
+
try {
|
|
199
|
+
final home = Platform.environment['HOME'] ??
|
|
200
|
+
Platform.environment['USERPROFILE'] ??
|
|
201
|
+
'';
|
|
202
|
+
final f = File('$home/.holon/credentials.json');
|
|
203
|
+
if (!f.existsSync()) return (principal: null, authToken: null);
|
|
204
|
+
final d = jsonDecode(f.readAsStringSync()) as Map;
|
|
205
|
+
final session = (d['session'] as Map?)?.cast<String, Object?>();
|
|
206
|
+
return (
|
|
207
|
+
principal: session?['uid']?.toString() ?? d['principal']?.toString(),
|
|
208
|
+
authToken: session?['accessToken']?.toString(),
|
|
209
|
+
);
|
|
210
|
+
} catch (_) {
|
|
211
|
+
return (principal: null, authToken: null);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static String? _jwtSub(String token) {
|
|
216
|
+
final parts = token.split('.');
|
|
217
|
+
if (parts.length != 3) return null;
|
|
218
|
+
try {
|
|
219
|
+
final d = jsonDecode(
|
|
220
|
+
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) as Map;
|
|
221
|
+
final sub = d['sub'];
|
|
222
|
+
return sub is String && sub.isNotEmpty ? sub : null;
|
|
223
|
+
} catch (_) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static String _stripSlash(String s) =>
|
|
229
|
+
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
|
230
|
+
|
|
231
|
+
static String _hex(int n) {
|
|
232
|
+
final rng = Random.secure();
|
|
233
|
+
return List.generate(n, (_) => rng.nextInt(16).toRadixString(16)).join();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.28.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The headless transport against the REAL node runner (`cloud/web-client/runner/node-entry.mjs`):
|
|
2
|
+
// protocol round-trip through NomosBridge without touching the cloud — ready handshake, typed refusal
|
|
3
|
+
// on a pre-connect op, unknown-op error. The live connect path is covered by the env-gated smoke below.
|
|
4
|
+
@TestOn('vm')
|
|
5
|
+
library;
|
|
6
|
+
|
|
7
|
+
import 'dart:io';
|
|
8
|
+
|
|
9
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
10
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
String _repoEntry() {
|
|
14
|
+
// In-repo the runner lives beside us; consumers resolve it from node_modules instead.
|
|
15
|
+
final here = Directory.current.path;
|
|
16
|
+
final p = '$here/../../cloud/web-client/runner/node-entry.mjs';
|
|
17
|
+
if (!File(p).existsSync()) {
|
|
18
|
+
throw StateError('node-entry.mjs not found at $p');
|
|
19
|
+
}
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
void main() {
|
|
24
|
+
late NodeRunnerTransport transport;
|
|
25
|
+
late NomosBridge bridge;
|
|
26
|
+
|
|
27
|
+
setUp(() async {
|
|
28
|
+
transport = await NodeRunnerTransport.start(entry: _repoEntry());
|
|
29
|
+
bridge = NomosBridge(transport);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
tearDown(() async {
|
|
33
|
+
await transport.dispose();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('posts the ready handshake', () async {
|
|
37
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a pre-connect op answers a typed bridge error', () async {
|
|
41
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
42
|
+
await expectLater(
|
|
43
|
+
bridge.head(),
|
|
44
|
+
throwsA(isA<NomosBridgeError>().having((e) => e.message, 'message',
|
|
45
|
+
contains('not connected'))),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('requests are id-correlated (two in flight)', () async {
|
|
50
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
51
|
+
final a = bridge.head().then((_) => 'ok', onError: (_) => 'err');
|
|
52
|
+
final b = bridge.metrics().then((_) => 'ok', onError: (_) => 'err');
|
|
53
|
+
expect(await a, 'err');
|
|
54
|
+
expect(await b, 'err');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test(
|
|
58
|
+
'LIVE smoke: connect + query against a real workspace',
|
|
59
|
+
skip: Platform.environment['NOMOS_HEADLESS_E2E'] == null
|
|
60
|
+
? 'set NOMOS_HEADLESS_E2E=<workspace> (and optionally NOMOS_CLOUD) to run'
|
|
61
|
+
: null,
|
|
62
|
+
() async {
|
|
63
|
+
final ws = Platform.environment['NOMOS_HEADLESS_E2E']!;
|
|
64
|
+
final cloud = Platform.environment['NOMOS_CLOUD'] ??
|
|
65
|
+
'https://nomos.captainapp.co.uk';
|
|
66
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
67
|
+
await bridge.connect(
|
|
68
|
+
cloud: cloud, workspace: ws, clientId: 'headless-test');
|
|
69
|
+
final head = await bridge.head();
|
|
70
|
+
expect(head, isNotEmpty);
|
|
71
|
+
},
|
|
72
|
+
timeout: const Timeout(Duration(minutes: 3)),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// NomosTestHarness: unit coverage for the identity/jwt plumbing, plus the LIVE ephemeral fixture loop
|
|
2
|
+
// (birth a throwaway ws → deploy real law → two client replicas → write on one, converge on the other).
|
|
3
|
+
// The live test needs a `githolon login --agent` session (or NOMOS_PRINCIPAL) and is opt-in via NOMOS_LIVE=1.
|
|
4
|
+
@TestOn('vm')
|
|
5
|
+
library;
|
|
6
|
+
|
|
7
|
+
import 'dart:convert';
|
|
8
|
+
import 'dart:io';
|
|
9
|
+
|
|
10
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
String _repoEntry() =>
|
|
14
|
+
'${Directory.current.path}/../../cloud/web-client/runner/node-entry.mjs';
|
|
15
|
+
|
|
16
|
+
String _guestbookDeployJson() =>
|
|
17
|
+
'${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
|
|
18
|
+
|
|
19
|
+
void main() {
|
|
20
|
+
test('ephemeral without any identity refuses with a named remedy', () async {
|
|
21
|
+
// isolate from a real ~/.holon by pointing HOME at an empty dir
|
|
22
|
+
// (can't re-env the current isolate — so only run when no creds file exists).
|
|
23
|
+
final home = Platform.environment['HOME'] ?? '';
|
|
24
|
+
if (File('$home/.holon/credentials.json').existsSync()) {
|
|
25
|
+
markTestSkipped('a githolon session exists — the fallback would find it');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await expectLater(
|
|
29
|
+
NomosTestHarness.ephemeral(),
|
|
30
|
+
throwsA(isA<NomosTestHarnessException>()),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test(
|
|
35
|
+
'LIVE: ephemeral fixture — birth, deploy law, write on one replica, read it on another',
|
|
36
|
+
skip: Platform.environment['NOMOS_LIVE'] != '1'
|
|
37
|
+
? 'set NOMOS_LIVE=1 (needs a githolon login --agent session)'
|
|
38
|
+
: null,
|
|
39
|
+
() async {
|
|
40
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
41
|
+
deployJsonPaths: [_guestbookDeployJson()],
|
|
42
|
+
nodeEntry: _repoEntry(),
|
|
43
|
+
);
|
|
44
|
+
try {
|
|
45
|
+
final writer = await harness.connect(clientId: 'writer');
|
|
46
|
+
final reader = await harness.connect(clientId: 'reader');
|
|
47
|
+
await writer.offerDirective(
|
|
48
|
+
domain: 'guestbook',
|
|
49
|
+
directiveId: 'signGuestbook',
|
|
50
|
+
payload: {
|
|
51
|
+
'author': 'harness',
|
|
52
|
+
'message': 'ephemeral fixture green',
|
|
53
|
+
'mood': 'delighted',
|
|
54
|
+
'signedAt': DateTime.now().toUtc().toIso8601String(),
|
|
55
|
+
});
|
|
56
|
+
await writer.sync(admit: true);
|
|
57
|
+
await reader.sync(admit: false);
|
|
58
|
+
final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
|
|
59
|
+
expect(rows, hasLength(1));
|
|
60
|
+
expect(
|
|
61
|
+
(rows.first['data'] as Map)['message'], 'ephemeral fixture green');
|
|
62
|
+
stdout.writeln(
|
|
63
|
+
'live harness green: ws=${harness.workspace} rows=${jsonEncode(rows.first['data'])}');
|
|
64
|
+
} finally {
|
|
65
|
+
await harness.dispose();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
timeout: const Timeout(Duration(minutes: 5)),
|
|
69
|
+
);
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|