feat(draw): d2 diagram template — compile d2 source to SVG (T-494)

The d2 drawing template compiles a diagram's source to SVG through the d2
binary (resolved via the D-104 path layer), then paints it with the same
renderer the svg card uses. `clide draw --file x.d2` infers the type from
the extension; `.svg` files render directly. Template handlers now return
a DrawResult so a compile failure or an unresolved d2 surface as an honest
userError with an install hint, not a generic "no SVG". Real d2 0.7.1
verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:19:48 +02:00
co-authored by Claude Opus 4.8
parent 67edf732f1
commit 64d77ec5dc
10 changed files with 234 additions and 19 deletions
+20
View File
@@ -8867,3 +8867,23 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', NULL, '2026-06-28 21:08:40', '2026-06-28 21:08:40.178', '2026-06-28 21:08:40.178', NULL, 'f155f8e589cd19c3cc52409495d24f50', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'description', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
Resolution order: (1) explicit settings path use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
First run: auto-detect each tool ONCE probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name absolute path), consulted FIRST.
Resolution order: (1) explicit settings path use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
First run: auto-detect each tool ONCE probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.640', '2026-06-29 07:32:43.640', NULL, '0dfb6eeb1bcde2f386379f029fa4d0f6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'status', 'in_progress', 'done', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.682', '2026-06-29 07:32:43.682', NULL, '45aeefee25353af62e4aa9f7b1f3265c', 2) ON CONFLICT(hash) DO NOTHING;
+22
View File
@@ -10656,3 +10656,25 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-28 21:08:40.177', NULL, '7e1f7077abd788309d38a51f8a3b1348', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
Resolution order: (1) explicit settings path use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
First run: auto-detect each tool ONCE probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.640', NULL, '5047cde2263e4df79fef6f4718f948fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
Resolution order: (1) explicit settings path use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
First run: auto-detect each tool ONCE probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
Storage: user-scope SettingsStore keyed by machine (D-93) never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'done', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.682', NULL, '70724bd4ac9c1da06f8d1c252492a4b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+4
View File
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to
SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"`
with inline source works too, and a `.svg` file renders directly. Honest error
if d2 isn't installed or the source doesn't compile. (T-494, D-103)
- **Tool path resolution + settings.** clide resolves supporter binaries (claude,
d2) via an explicit per-tool path, else PATH and the common install dirs — now
including Homebrew-on-Linux. A Tools settings category edits the paths and
+2 -1
View File
@@ -41,6 +41,7 @@ import 'package:clide/clide.dart' show clideVersion;
import 'package:clide/src/daemon/claude_account_commands.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/daemon/draw_commands.dart';
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
@@ -376,7 +377,7 @@ Future<void> main() async {
registerDrawCommands(
dispatcher,
() => kernelMessages?.publish,
registry: DrawingRegistry(),
registry: DrawingRegistry()..register('d2', d2TemplateHandler()),
readFile: (path) async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
try {
+19 -9
View File
@@ -18,7 +18,7 @@ import 'dart:convert';
import '../draw/draw_dispatch.dart';
import '../draw/draw_doc.dart';
export '../draw/draw_dispatch.dart' show DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
export '../draw/draw_dispatch.dart' show DrawErr, DrawOk, DrawResult, DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
@@ -66,16 +66,26 @@ Future<IpcResponse> _draw(IpcRequest req, MessagePublisher? Function() publisher
);
}
Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
// Type inference from the extension (T-494): a `.d2`/`.svg` file is the raw
// source, not a JSON envelope — wrap it in the matching doc. Everything else
// is a drawing-card JSON document.
final lower = file.toLowerCase();
final DrawingCardDoc? doc;
if (lower.endsWith('.d2')) {
doc = DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': raw});
} else if (lower.endsWith('.svg')) {
doc = DrawingCardDoc(svg: raw);
} else {
final Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
}
doc = parseDrawingCardDoc(decoded);
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
}
final doc = parseDrawingCardDoc(decoded);
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
final result = await resolveDrawingSvg(doc, registry, readFile: readFile);
if (result is DrawErr) return _userErr(req.id, result.message);
final svg = (result as DrawOk).svg;
+80
View File
@@ -0,0 +1,80 @@
/// The `d2` drawing-card template (T-494 / D-91 / D-103).
///
/// A d2 diagram is just an SVG card with a compile step in front: the doc's
/// `source` (d2 diagram text) is compiled to SVG, then painted by the SAME
/// renderer the `svg` card uses (T-320). The compile shells out to the `d2`
/// binary — the supporter-tool pattern (peer of pql/git, D-3/D-5), resolved via
/// the D-104 path layer (T-495). No second core language, no vendored Go.
///
/// Honest failures (D-103): a missing source, an unresolved `d2`, or a compile
/// error each return a [DrawErr] with a user-facing message + hint, which the
/// command layer turns into an IpcError userError — never a throw.
///
/// Flutter-free: pure Dart (dart:io), runs under `dart test`. The process spawn
/// is injectable ([D2Compiler]) so the handler is tested without a real binary.
library;
import 'dart:convert';
import 'dart:io';
import '../env/supporter_binaries.dart';
import 'draw_dispatch.dart';
/// Compiles d2 [source] to an SVG [DrawResult]. Injected into
/// [d2TemplateHandler] so it is testable; the default is [d2CompileViaBinary].
typedef D2Compiler = Future<DrawResult> Function(String source);
/// Handler for `template: "d2"` — reads the doc's `source` field (the diagram
/// text) and compiles it. Register this in the [DrawingRegistry].
DrawingTemplateHandler d2TemplateHandler({D2Compiler compile = d2CompileViaBinary}) {
return (doc) async {
final source = doc.fields['source'];
if (source is! String || source.trim().isEmpty) {
return const DrawErr('the d2 template needs a non-empty "source" field (the d2 diagram text)');
}
return compile(source);
};
}
/// One run of the d2 binary: its exit code, stdout (SVG) and stderr.
typedef D2RunResult = ({int code, String out, String err});
/// Runs the d2 [exe] over [source]. Injected so [d2CompileViaBinary] is tested
/// without a real binary; the default is [_spawnD2].
typedef D2Run = Future<D2RunResult> Function(String exe, String source);
/// Resolve the `d2` binary (D-104) and compile [source] through it. Failure keys
/// off the exit code — d2 logs `success:` to stderr on a clean compile, so a
/// non-empty stderr is not itself an error. [resolveD2] and [run] are injectable
/// for testing; the defaults use [activeSupporterBinaries] and a real spawn.
Future<DrawResult> d2CompileViaBinary(String source, {String? Function()? resolveD2, D2Run run = _spawnD2}) async {
final d2 = (resolveD2 ?? _defaultResolveD2)();
if (d2 == null) {
return const DrawErr('d2 not found — install it from https://d2lang.com, or set its path in Settings → Tools');
}
final D2RunResult r;
try {
r = await run(d2, source);
} catch (e) {
return DrawErr('could not run d2 ($d2): $e');
}
if (r.code != 0) {
return DrawErr('d2 compile failed: ${r.err.trim().isEmpty ? 'exit ${r.code}' : r.err.trim()}');
}
if (r.out.trim().isEmpty) return const DrawErr('d2 produced no SVG');
return DrawOk(r.out);
}
String? _defaultResolveD2() => (activeSupporterBinaries ?? SupporterBinaries()).resolve('d2');
/// `d2 - -` — read source on stdin, write SVG to stdout. Drains stdout/stderr
/// concurrently with the stdin write to avoid a pipe deadlock on a big diagram.
Future<D2RunResult> _spawnD2(String exe, String source) async {
final proc = await Process.start(exe, const ['-', '-']);
final outF = proc.stdout.transform(utf8.decoder).join();
final errF = proc.stderr.transform(utf8.decoder).join();
proc.stdin.write(source);
await proc.stdin.close();
final code = await proc.exitCode;
return (code: code, out: await outF, err: await errF);
}
+5 -4
View File
@@ -17,8 +17,10 @@ library;
import 'draw_doc.dart';
/// Produces an SVG string for a template-mode doc, or `null` on failure.
typedef DrawingTemplateHandler = Future<String?> Function(DrawingCardDoc doc);
/// Lowers a template-mode doc to SVG, as a [DrawResult] — [DrawOk] with the SVG
/// or [DrawErr] carrying an honest, user-facing message (e.g. a compile failure
/// or an unresolved tool, with a hint).
typedef DrawingTemplateHandler = Future<DrawResult> Function(DrawingCardDoc doc);
/// Reads a file's contents, or `null` if unreadable. Injected for testability.
typedef DrawingFileReader = Future<String?> Function(String path);
@@ -65,6 +67,5 @@ Future<DrawResult> resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registr
final handler = registry.handlerFor(doc.template!);
if (handler == null) return DrawErr('unknown drawing template: ${doc.template}');
final svg = await handler(doc);
return svg == null ? DrawErr('template ${doc.template} produced no SVG') : DrawOk(svg);
return handler(doc);
}
+19 -1
View File
@@ -48,7 +48,7 @@ void main() {
test('template doc lowers via the registry before publishing', () async {
wire();
registry.register('d2', (doc) async => '<svg data-d2="${doc.fields['source']}"/>');
registry.register('d2', (doc) async => DrawOk('<svg data-d2="${doc.fields['source']}"/>'));
files['c.json'] = '{"template":"d2","source":"a -> b"}';
final r = await draw('c.json');
expect(r.ok, isTrue, reason: r.error?.message);
@@ -56,6 +56,24 @@ void main() {
expect(r.data['template'], 'd2');
});
test('a .d2 file is wrapped as a d2 template and lowered (T-494)', () async {
wire();
registry.register('d2', (doc) async => DrawOk('<svg data-d2="${doc.fields['source']}"/>'));
files['pipeline.d2'] = 'a -> b';
final r = await draw('pipeline.d2');
expect(r.ok, isTrue, reason: r.error?.message);
expect(published.single.data['svg'], '<svg data-d2="a -> b"/>');
expect(r.data['template'], 'd2');
});
test('a .svg file renders as a primitive card (T-494)', () async {
wire();
files['logo.svg'] = '<svg id="raw"/>';
final r = await draw('logo.svg');
expect(r.ok, isTrue, reason: r.error?.message);
expect(published.single.data['svg'], '<svg id="raw"/>');
});
test('a missing file → notFound, nothing published', () async {
wire();
final r = await draw('nope.json');
+59
View File
@@ -0,0 +1,59 @@
import 'package:clide/src/draw/d2_template.dart';
import 'package:clide/src/draw/draw_dispatch.dart';
import 'package:clide/src/draw/draw_doc.dart';
import 'package:test/test.dart';
void main() {
DrawingCardDoc d2doc(Object? source) => DrawingCardDoc(template: 'd2', fields: {'template': 'd2', if (source != null) 'source': source});
group('d2TemplateHandler', () {
test('compiles the source field via the injected compiler', () async {
final h = d2TemplateHandler(compile: (s) async => DrawOk('<svg data-s="$s"/>'));
final r = await h(d2doc('a -> b'));
expect((r as DrawOk).svg, '<svg data-s="a -> b"/>');
});
test('a missing source is an honest error', () async {
final h = d2TemplateHandler(compile: (_) async => const DrawOk('x'));
expect(await h(d2doc(null)), isA<DrawErr>());
});
test('a blank source is an honest error', () async {
final h = d2TemplateHandler(compile: (_) async => const DrawOk('x'));
expect(await h(d2doc(' ')), isA<DrawErr>());
});
test('propagates a compiler error message', () async {
final h = d2TemplateHandler(compile: (_) async => const DrawErr('boom'));
expect((await h(d2doc('a -> b')) as DrawErr).message, 'boom');
});
});
group('d2CompileViaBinary', () {
Future<DrawResult> compile({String? bin = '/x/d2', D2Run? run}) =>
d2CompileViaBinary('a -> b', resolveD2: () => bin, run: run ?? (exe, src) async => (code: 0, out: '<svg/>', err: 'success:'));
test('unresolved d2 hints how to install', () async {
expect((await compile(bin: null) as DrawErr).message, contains('d2 not found'));
});
test('a clean compile returns the SVG (stderr ignored)', () async {
expect((await compile() as DrawOk).svg, '<svg/>');
});
test('a non-zero exit is a compile error carrying stderr', () async {
final r = await compile(run: (exe, src) async => (code: 1, out: '', err: 'line 2: syntax error'));
expect((r as DrawErr).message, contains('syntax error'));
});
test('empty output is an error', () async {
final r = await compile(run: (exe, src) async => (code: 0, out: ' ', err: ''));
expect(r, isA<DrawErr>());
});
test('a spawn failure is reported honestly', () async {
final r = await compile(run: (exe, src) async => throw 'ENOENT');
expect((r as DrawErr).message, contains('could not run d2'));
});
});
}
+4 -4
View File
@@ -37,15 +37,15 @@ void main() {
});
test('template: a registered handler lowers the doc to SVG', () async {
final reg = DrawingRegistry()..register('d2', (doc) async => '<svg data-src="${doc.fields['source']}"/>');
final reg = DrawingRegistry()..register('d2', (doc) async => DrawOk('<svg data-src="${doc.fields['source']}"/>'));
final r = await resolve(parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!, reg);
expect((r as DrawOk).svg, '<svg data-src="a -> b"/>');
});
test('template: a handler that returns null is an error', () async {
final reg = DrawingRegistry()..register('d2', (_) async => null);
test('template: a handler error propagates with its message', () async {
final reg = DrawingRegistry()..register('d2', (_) async => const DrawErr('d2 not found'));
final r = await resolve(parseDrawingCardDoc({'template': 'd2'})!, reg);
expect(r, isA<DrawErr>());
expect((r as DrawErr).message, contains('d2 not found'));
});
});
}