660 lines
31 KiB
Zig
660 lines
31 KiB
Zig
//! End-to-end proof battery for examples/ai-chat-ts — the "can I call an
|
|
//! AI API?" answer as a real app: a chat client for an OpenAI-compatible
|
|
//! chat-completions endpoint authored in TypeScript + Native markup with
|
|
//! ZERO hand-written Zig. The build transpiles the example's REAL core
|
|
//! (examples/ai-chat-ts/src/core.ts + src/api.ts) and this suite drives
|
|
//! it through `TsUiApp` with the example's SHIPPING markup (app.native,
|
|
//! staged beside this file), so every pin here is the product path:
|
|
//!
|
|
//! - the launch configuration rides the envMsgs channel, and the
|
|
//! teaching state holds (with ZERO fetches) until the endpoint, the
|
|
//! model name, AND the API key are all present;
|
|
//! - a scripted two-turn conversation drives the whole loop through
|
|
//! the fake fetch feed: the composer's byte-splice text engine, the
|
|
//! Send press, the EXACT request bytes (method, the bare endpoint
|
|
//! url, the runtime-built `authorization: Bearer <key>` header plus
|
|
//! the content-type header, the JSON body — system prompt first,
|
|
//! history growing turn by turn), and the response parse into the
|
|
//! committed model;
|
|
//! - the in-flight guard: a second send while one request is out
|
|
//! issues nothing and loses nothing (the draft survives);
|
|
//! - every failure shape lands in the failed state with a reason and
|
|
//! KEEPS the history: a 500 with an error body (the endpoint's own
|
|
//! error.message surfaces), a 200 whose body does not parse, a bare
|
|
//! non-200, a transport failure — and Retry re-sends the same
|
|
//! conversation;
|
|
//! - a recorded conversation REPLAYS BYTE-IDENTICALLY with zero host
|
|
//! calls — no endpoint, no network in the room (the journaled fetch
|
|
//! results feed the replayed requests), and with ZERO env reads:
|
|
//! the launch configuration is journaled (`.env` records) at record
|
|
//! time, so the replay launches with the variables UNSET and again
|
|
//! with them CHANGED and both replay identically — the
|
|
//! announcement's replay-an-AI-conversation trick, pinned.
|
|
//!
|
|
//! Only this TEST wiring is Zig — the command mapper below exists so the
|
|
//! suite can dispatch send/retry/clear through the journaled
|
|
//! menu-command path where a scripted session needs it (the app itself
|
|
//! dispatches them from markup).
|
|
|
|
const std = @import("std");
|
|
const native_sdk = @import("native_sdk");
|
|
const core = @import("ts_ai_chat_core");
|
|
|
|
const runtime_ns = native_sdk.runtime;
|
|
const canvas = native_sdk.canvas;
|
|
const geometry = native_sdk.geometry;
|
|
|
|
const Adapter = native_sdk.TsUiApp(core);
|
|
const App = Adapter.App;
|
|
const Bridge = Adapter.Host;
|
|
|
|
const app_markup = @embedFile("app.native");
|
|
const CompiledAppView = canvas.CompiledMarkupView(core.Model, core.Msg, app_markup);
|
|
|
|
const canvas_label = "chat-canvas";
|
|
|
|
/// The one fetch key's engine slot: the "chat" request is the first (and
|
|
/// only) named engine op the core issues, so it takes bridge op slot 0,
|
|
/// deterministically in issue order.
|
|
const chat_fetch_key: u64 = runtime_ns.ts_core_effect_key_base + 0;
|
|
|
|
const test_endpoint = "http://chat.test/v1/chat/completions";
|
|
const test_model_name = "test-model";
|
|
const test_api_key = "test-key";
|
|
|
|
const app_views = [_]native_sdk.ShellView{
|
|
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
|
|
};
|
|
const app_windows = [_]native_sdk.ShellWindow{.{
|
|
.label = "main",
|
|
.title = "AI Chat TS",
|
|
.width = 760,
|
|
.height = 640,
|
|
.views = &app_views,
|
|
}};
|
|
const app_scene: native_sdk.ShellConfig = .{ .windows = &app_windows };
|
|
|
|
/// TEST-ONLY command mapper: the journaled menu-command path for the
|
|
/// void arms (record/replay needs every input in the journal; the app
|
|
/// itself dispatches these from markup presses).
|
|
fn testCommand(name: []const u8) ?core.Msg {
|
|
if (std.mem.eql(u8, name, "chat.send")) return .send;
|
|
if (std.mem.eql(u8, name, "chat.retry")) return .retry;
|
|
if (std.mem.eql(u8, name, "chat.clear")) return .clear;
|
|
// The recorded session's composer input, as journaled commands: the
|
|
// synthesized pointer/text events a click-and-type gesture journals
|
|
// carry wall-clock timestamps, so the byte-identity pin below drives
|
|
// the SAME draft_edit path through the timestamp-free menu channel
|
|
// (the real input path is pinned by the conversation tests above).
|
|
if (std.mem.eql(u8, name, "chat.say.one")) return .{ .draft_edit = .{ .insert_text = "Say hi in two words" } };
|
|
if (std.mem.eql(u8, name, "chat.say.two")) return .{ .draft_edit = .{ .insert_text = "And a follow-up?" } };
|
|
return null;
|
|
}
|
|
|
|
fn appOptions() App.Options {
|
|
return .{
|
|
.name = "ai-chat-ts-e2e",
|
|
.scene = app_scene,
|
|
.canvas_label = canvas_label,
|
|
// The comptime-compiled engine over the example's shipping markup
|
|
// — the whole view tier of the app under test.
|
|
.view = CompiledAppView.build,
|
|
.on_command = testCommand,
|
|
};
|
|
}
|
|
|
|
/// The full launch configuration — every variable present.
|
|
const configured_env = [_]Adapter.EnvValue{
|
|
.{ .msg = "endpoint_set", .value = test_endpoint },
|
|
.{ .msg = "model_set", .value = test_model_name },
|
|
.{ .msg = "key_set", .value = test_api_key },
|
|
};
|
|
|
|
const Harness = struct {
|
|
harness: *native_sdk.TestHarness(),
|
|
app_state: *App,
|
|
app: native_sdk.App,
|
|
clock: native_sdk.TestClock,
|
|
|
|
/// A configured app on the FAKE effects executor: fetch requests
|
|
/// park in fake slots for `feedResponse` answers instead of
|
|
/// reaching a network.
|
|
fn create() !*Harness {
|
|
return createConfigured(null, &configured_env);
|
|
}
|
|
|
|
fn createConfigured(recorder: ?*runtime_ns.SessionRecorder, env_values: []const Adapter.EnvValue) !*Harness {
|
|
const self = try std.testing.allocator.create(Harness);
|
|
errdefer std.testing.allocator.destroy(self);
|
|
self.clock = .{};
|
|
self.clock.setWallMs(60_000);
|
|
self.harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
|
|
.size = geometry.SizeF.init(760, 640),
|
|
});
|
|
errdefer self.harness.destroy(std.testing.allocator);
|
|
self.harness.null_platform.gpu_surfaces = true;
|
|
self.harness.runtime.options.session_recorder = recorder;
|
|
self.app_state = try std.testing.allocator.create(App);
|
|
errdefer std.testing.allocator.destroy(self.app_state);
|
|
self.app_state.* = Adapter.init(std.heap.page_allocator, .{ .env_values = env_values }, appOptions());
|
|
self.app_state.effects.executor = .fake;
|
|
self.app_state.effects.clock = self.clock.clock();
|
|
self.app = self.app_state.app();
|
|
try self.harness.start(self.app);
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_frame = .{
|
|
.label = canvas_label,
|
|
.size = geometry.SizeF.init(760, 640),
|
|
.scale_factor = 1,
|
|
.frame_index = 1,
|
|
.timestamp_ns = 1_000_000,
|
|
} });
|
|
try std.testing.expect(self.app_state.installed);
|
|
return self;
|
|
}
|
|
|
|
fn destroy(self: *Harness) void {
|
|
self.app_state.deinit();
|
|
std.testing.allocator.destroy(self.app_state);
|
|
self.harness.destroy(std.testing.allocator);
|
|
std.testing.allocator.destroy(self);
|
|
}
|
|
|
|
fn wake(self: *Harness) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .wake);
|
|
}
|
|
|
|
fn menu(self: *Harness, name: []const u8) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .menu_command = .{ .name = name, .window_id = 1 } });
|
|
}
|
|
|
|
fn hasText(self: *Harness, text: []const u8) bool {
|
|
return findTextIn(self.app_state.tree.?.root, text);
|
|
}
|
|
|
|
fn findId(self: *Harness, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
|
|
return findKindText(self.app_state.tree.?.root, kind, text);
|
|
}
|
|
|
|
fn findLabel(self: *Harness, label: []const u8) ?canvas.ObjectId {
|
|
return findByLabel(self.app_state.tree.?.root, label);
|
|
}
|
|
|
|
/// Click a rendered widget through the automation verb — the same
|
|
/// headless path `native automate` drives.
|
|
fn click(self: *Harness, id: canvas.ObjectId) !void {
|
|
var buffer: [96]u8 = undefined;
|
|
const command = try std.fmt.bufPrint(&buffer, "widget-click {s} {d}", .{ canvas_label, id });
|
|
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
|
|
}
|
|
|
|
fn textInput(self: *Harness, text: []const u8) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .text_input,
|
|
.text = text,
|
|
} });
|
|
}
|
|
|
|
fn keyDown(self: *Harness, key: []const u8) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .key_down,
|
|
.key = key,
|
|
} });
|
|
}
|
|
|
|
/// Focus the composer, type the message, and press Send — the whole
|
|
/// user gesture through the real input path.
|
|
fn say(self: *Harness, text: []const u8) !void {
|
|
try self.click(self.findLabel("Message").?);
|
|
try self.textInput(text);
|
|
try self.click(self.findLabel("Send message").?);
|
|
}
|
|
|
|
/// Answer the parked chat request with a scripted HTTP response and
|
|
/// drain the result into the core.
|
|
fn respond(self: *Harness, status: u16, body: []const u8) !void {
|
|
try self.app_state.effects.feedResponse(chat_fetch_key, status, body);
|
|
try self.wake();
|
|
}
|
|
};
|
|
|
|
fn findKindText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
|
|
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget.id;
|
|
for (widget.children) |child| {
|
|
if (findKindText(child, kind, text)) |id| return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn findTextIn(widget: canvas.Widget, text: []const u8) bool {
|
|
if (std.mem.indexOf(u8, widget.text, text) != null) return true;
|
|
for (widget.children) |child| {
|
|
if (findTextIn(child, text)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn findByLabel(widget: canvas.Widget, label: []const u8) ?canvas.ObjectId {
|
|
if (std.mem.eql(u8, widget.semantics.label, label)) return widget.id;
|
|
for (widget.children) |child| {
|
|
if (findByLabel(child, label)) |id| return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn findWidgetByLabel(widget: canvas.Widget, label: []const u8) ?canvas.Widget {
|
|
if (std.mem.eql(u8, widget.semantics.label, label)) return widget;
|
|
for (widget.children) |child| {
|
|
if (findWidgetByLabel(child, label)) |found| return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The pinned request bodies: the system prompt first, then the history,
|
|
// oldest first — growing turn by turn. Byte-exact, because the encoder
|
|
// is deterministic byte math (that is what makes replay work).
|
|
const first_request_body =
|
|
"{\"model\":\"test-model\",\"messages\":[" ++
|
|
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
|
"{\"role\":\"user\",\"content\":\"Say hi in two words\"}]}";
|
|
const second_request_body =
|
|
"{\"model\":\"test-model\",\"messages\":[" ++
|
|
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
|
"{\"role\":\"user\",\"content\":\"Say hi in two words\"}," ++
|
|
"{\"role\":\"assistant\",\"content\":\"Hi there!\"}," ++
|
|
"{\"role\":\"user\",\"content\":\"Now say it in Zig \\\"strings\\\"\"}]}";
|
|
|
|
// ---------------------------------------------------- the teaching state
|
|
|
|
test "the teaching state holds until every launch variable arrives - and issues zero fetches" {
|
|
// No variables at all: the setup panel teaches all three names.
|
|
{
|
|
const h = try Harness.createConfigured(null, &.{});
|
|
defer h.destroy();
|
|
try std.testing.expect(h.hasText("Connect a model"));
|
|
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_ENDPOINT"));
|
|
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_MODEL"));
|
|
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_API_KEY"));
|
|
try std.testing.expect(h.hasText("no model configured"));
|
|
// The composer does not exist in the teaching state, and even a
|
|
// journaled send dispatch issues nothing.
|
|
try std.testing.expect(h.findLabel("Message") == null);
|
|
try h.menu("chat.send");
|
|
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingFetchCount());
|
|
}
|
|
|
|
// Endpoint and model present, the key EMPTY (the variable exists but
|
|
// holds nothing): still the teaching state, still zero fetches — an
|
|
// unkeyed app never dials the endpoint.
|
|
{
|
|
const partial_env = [_]Adapter.EnvValue{
|
|
.{ .msg = "endpoint_set", .value = test_endpoint },
|
|
.{ .msg = "model_set", .value = test_model_name },
|
|
.{ .msg = "key_set", .value = "" },
|
|
};
|
|
const h = try Harness.createConfigured(null, &partial_env);
|
|
defer h.destroy();
|
|
try std.testing.expect(h.hasText("Connect a model"));
|
|
// The two configured rows read "set"; the key row still teaches.
|
|
try std.testing.expect(h.hasText("set"));
|
|
try std.testing.expect(h.hasText("missing"));
|
|
try std.testing.expect(h.hasText(test_model_name));
|
|
try h.menu("chat.send");
|
|
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingFetchCount());
|
|
try std.testing.expect(core.unconfigured(Bridge.model()));
|
|
}
|
|
}
|
|
|
|
test "the runtime markup interpreter builds the emitted model exactly like the compiled engine" {
|
|
// The PRODUCT wiring runs app.native through the runtime interpreter
|
|
// (hot reload); this suite compiles it at comptime. Hold the two
|
|
// engines text-identical over the booted model so the product path
|
|
// can never drift from the tested one.
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const model = h.app_state.model;
|
|
const AppUi = canvas.Ui(core.Msg);
|
|
var interpreter_view = try canvas.MarkupView(core.Model, core.Msg).init(arena, app_markup);
|
|
var interpreter_ui = AppUi.init(arena);
|
|
const interpreted = try interpreter_ui.finalize(try interpreter_view.build(&interpreter_ui, &model));
|
|
var compiled_ui = AppUi.init(arena);
|
|
const compiled = try compiled_ui.finalize(CompiledAppView.build(&compiled_ui, &model));
|
|
|
|
var interpreted_texts: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer interpreted_texts.deinit(std.testing.allocator);
|
|
var compiled_texts: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer compiled_texts.deinit(std.testing.allocator);
|
|
try collectTexts(interpreted.root, &interpreted_texts, std.testing.allocator);
|
|
try collectTexts(compiled.root, &compiled_texts, std.testing.allocator);
|
|
try std.testing.expectEqualStrings(interpreted_texts.items, compiled_texts.items);
|
|
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "Ask anything") != null);
|
|
}
|
|
|
|
fn collectTexts(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void {
|
|
try out.appendSlice(allocator, widget.text);
|
|
try out.append(allocator, '\n');
|
|
for (widget.children) |child| {
|
|
try collectTexts(child, out, allocator);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------ the conversation
|
|
|
|
test "a scripted conversation pins the exact request bytes, the parse, and the history growth" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
const fx = &h.app_state.effects;
|
|
|
|
// The configured idle state: the model badge and the empty hint.
|
|
try std.testing.expect(h.hasText(test_model_name));
|
|
try std.testing.expect(h.hasText("Ask anything"));
|
|
|
|
// Type into the composer through the real text-input path (the
|
|
// core's byte-splice engine) and send. The engine channel holds the
|
|
// request whole: POST, the bare configured endpoint, the
|
|
// runtime-built `authorization: Bearer <key>` header (header VALUES
|
|
// may be runtime bytes; the key never rides the URL) plus the JSON
|
|
// content-type header in name-sort order, and the byte-exact body.
|
|
try h.say("Say hi in two words");
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
const request = fx.pendingFetchAt(0).?;
|
|
try std.testing.expectEqual(chat_fetch_key, request.key);
|
|
try std.testing.expectEqual(std.http.Method.POST, request.method);
|
|
try std.testing.expectEqualStrings(test_endpoint, request.url);
|
|
try std.testing.expectEqual(@as(usize, 2), request.headers.len);
|
|
try std.testing.expectEqualStrings("authorization", request.headers[0].name);
|
|
try std.testing.expectEqualStrings("Bearer " ++ test_api_key, request.headers[0].value);
|
|
try std.testing.expectEqualStrings("content-type", request.headers[1].name);
|
|
try std.testing.expectEqualStrings("application/json", request.headers[1].value);
|
|
try std.testing.expectEqualStrings(first_request_body, request.body);
|
|
|
|
// The optimistic model: the user turn committed, the draft cleared,
|
|
// the honest sending state on screen.
|
|
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
|
try std.testing.expect(Bridge.model().turns[0].role == .user);
|
|
try std.testing.expectEqualStrings("Say hi in two words", Bridge.model().turns[0].text);
|
|
try std.testing.expect(Bridge.model().phase == .sending);
|
|
try std.testing.expectEqual(@as(usize, 0), core.draftText(Bridge.model()).len);
|
|
try std.testing.expect(h.hasText("waiting for the model"));
|
|
|
|
// The endpoint answers; the reply parses out of choices[0] (escapes
|
|
// decoded) and joins the history as the assistant turn.
|
|
try h.respond(200, "{ \"id\": \"c-1\", \"object\": \"chat.completion\", \"choices\": [ { \"index\": 0, \"message\": { \"role\": \"assistant\", \"content\": \"Hi there!\" }, \"finish_reason\": \"stop\" } ], \"usage\": { \"total_tokens\": 7 } }");
|
|
try std.testing.expect(Bridge.model().phase == .idle);
|
|
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
|
try std.testing.expect(Bridge.model().turns[1].role == .assistant);
|
|
try std.testing.expectEqualStrings("Hi there!", Bridge.model().turns[1].text);
|
|
try std.testing.expect(h.hasText("Hi there!"));
|
|
|
|
// Turn two: the request body carries the WHOLE history — system
|
|
// prompt, both turns, the new user message with its escapes encoded.
|
|
try h.say("Now say it in Zig \"strings\"");
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
try std.testing.expectEqualStrings(second_request_body, fx.pendingFetchAt(0).?.body);
|
|
|
|
// A reply whose content carries JSON escapes decodes into real
|
|
// bytes: the \n and the \" land in the committed turn.
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"const hi =\\n \\\"hi\\\";\"}}]}");
|
|
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
|
try std.testing.expectEqualStrings("const hi =\n \"hi\";", Bridge.model().turns[3].text);
|
|
}
|
|
|
|
test "the in-flight guard: a second send issues nothing and loses nothing" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
const fx = &h.app_state.effects;
|
|
|
|
try h.say("first question");
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
|
|
|
// Type a follow-up while the request is out: the Send button is
|
|
// DISABLED (the markup binds the same guard update enforces — a
|
|
// click is impossible), and even the journaled command path issues
|
|
// nothing — no second fetch, no phantom turn, and the draft
|
|
// SURVIVES (a blocked send loses nothing).
|
|
try h.click(h.findLabel("Message").?);
|
|
try h.textInput("eager follow-up");
|
|
try std.testing.expect(findWidgetByLabel(h.app_state.tree.?.root, "Send message").?.state.disabled);
|
|
try h.menu("chat.send");
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
|
try std.testing.expectEqualStrings("eager follow-up", core.draftText(Bridge.model()));
|
|
|
|
// The reply lands and the guard lifts: the surviving draft sends
|
|
// through the journaled command path.
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"answer\"}}]}");
|
|
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
|
try h.menu("chat.send");
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
|
|
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
|
try h.click(h.findLabel("Message").?);
|
|
try h.textInput(" ");
|
|
try h.click(h.findLabel("Send message").?);
|
|
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
|
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
|
}
|
|
|
|
// ---------------------------------------------------------- failure paths
|
|
|
|
test "every failure shape lands in the failed state with a reason and keeps the history" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
const fx = &h.app_state.effects;
|
|
|
|
// A 500 with a chat-completions error body: the endpoint's own
|
|
// error.message surfaces, and the unanswered user turn stays.
|
|
try h.say("hello?");
|
|
try h.respond(500, "{ \"error\": { \"message\": \"model overloaded\", \"type\": \"server_error\" } }");
|
|
try std.testing.expect(Bridge.model().phase == .failed);
|
|
try std.testing.expectEqualStrings("model overloaded", Bridge.model().failReason);
|
|
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
|
try std.testing.expect(h.hasText("Request failed"));
|
|
try std.testing.expect(h.hasText("model overloaded"));
|
|
try std.testing.expect(h.hasText("hello?"));
|
|
|
|
// Retry re-sends the SAME conversation (no new turn, same history)
|
|
// and a success resolves it.
|
|
try h.click(h.findLabel("Retry request").?);
|
|
try std.testing.expect(Bridge.model().phase == .sending);
|
|
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
|
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"hi\"}}]}");
|
|
try std.testing.expect(Bridge.model().phase == .idle);
|
|
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
|
|
|
// A 200 whose body is not a chat completion is failed, never a
|
|
// half-parsed conversation.
|
|
try h.say("again");
|
|
try h.respond(200, "<html>gateway error</html>");
|
|
try std.testing.expect(Bridge.model().phase == .failed);
|
|
try std.testing.expectEqualStrings("the response did not parse as a chat completion", Bridge.model().failReason);
|
|
try std.testing.expectEqual(@as(usize, 3), Bridge.model().turns.len);
|
|
|
|
// A non-200 without an error body reads as its status line.
|
|
try h.click(h.findLabel("Retry request").?);
|
|
try h.respond(404, "not found");
|
|
try std.testing.expect(Bridge.model().phase == .failed);
|
|
try std.testing.expectEqualStrings("the endpoint answered HTTP 404", Bridge.model().failReason);
|
|
|
|
// A transport failure surfaces the engine's machine-readable reason.
|
|
try h.click(h.findLabel("Retry request").?);
|
|
try fx.feedResponseOutcome(chat_fetch_key, .timed_out, 0, "");
|
|
try h.wake();
|
|
try std.testing.expect(Bridge.model().phase == .failed);
|
|
try std.testing.expectEqualStrings("timed_out", Bridge.model().failReason);
|
|
try std.testing.expect(h.hasText("timed_out"));
|
|
|
|
// The history survived the whole gauntlet, and Clear resets it.
|
|
try std.testing.expectEqual(@as(usize, 3), Bridge.model().turns.len);
|
|
try h.menu("chat.clear");
|
|
try std.testing.expectEqual(@as(usize, 0), Bridge.model().turns.len);
|
|
try std.testing.expect(Bridge.model().phase == .idle);
|
|
try std.testing.expect(h.hasText("Ask anything"));
|
|
}
|
|
|
|
// -------------------------------------------------------- record / replay
|
|
|
|
const JournalBuffer = struct {
|
|
bytes: [512 * 1024]u8 = undefined,
|
|
len: usize = 0,
|
|
|
|
fn sink(self: *JournalBuffer) runtime_ns.SessionRecorderSink {
|
|
return .{ .context = self, .write_fn = write };
|
|
}
|
|
|
|
fn write(context: *anyopaque, bytes: []const u8) anyerror!void {
|
|
const self: *JournalBuffer = @ptrCast(@alignCast(context));
|
|
if (self.len + bytes.len > self.bytes.len) return error.NoSpaceLeft;
|
|
@memcpy(self.bytes[self.len .. self.len + bytes.len], bytes);
|
|
self.len += bytes.len;
|
|
}
|
|
|
|
fn journalBytes(self: *const JournalBuffer) []const u8 {
|
|
return self.bytes[0..self.len];
|
|
}
|
|
};
|
|
|
|
/// A value snapshot of the committed chat model (committed slices live
|
|
/// in the core's heap — copy what outlives a session).
|
|
const ChatSnapshot = struct {
|
|
turns_len: usize,
|
|
next_id: i64,
|
|
phase: core.Phase,
|
|
last_turn: [256]u8,
|
|
last_turn_len: usize,
|
|
|
|
fn take() ChatSnapshot {
|
|
const m = Bridge.model();
|
|
var self: ChatSnapshot = .{
|
|
.turns_len = m.turns.len,
|
|
.next_id = m.nextId,
|
|
.phase = m.phase,
|
|
.last_turn = undefined,
|
|
.last_turn_len = 0,
|
|
};
|
|
if (m.turns.len > 0) {
|
|
const text = m.turns[m.turns.len - 1].text;
|
|
self.last_turn_len = @min(text.len, self.last_turn.len);
|
|
@memcpy(self.last_turn[0..self.last_turn_len], text[0..self.last_turn_len]);
|
|
}
|
|
return self;
|
|
}
|
|
};
|
|
|
|
/// One reference session: journaled user input (draft edits and sends
|
|
/// through the timestamp-free menu-command path — see testCommand) plus
|
|
/// the scripted fetch feed — a two-turn conversation with one
|
|
/// mid-session transport failure and its retry.
|
|
fn recordSession(buffer: *JournalBuffer) !ChatSnapshot {
|
|
const recorder = try std.heap.page_allocator.create(runtime_ns.SessionRecorder);
|
|
defer std.heap.page_allocator.destroy(recorder);
|
|
recorder.* = runtime_ns.SessionRecorder.init(buffer.sink());
|
|
recorder.begin(.{ .platform_name = "test", .app_name = "ai-chat-ts-e2e", .window_width = 760, .window_height = 640 });
|
|
|
|
const h = try Harness.createConfigured(recorder, &configured_env);
|
|
defer h.destroy();
|
|
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
try h.menu("chat.say.one");
|
|
try h.menu("chat.send");
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"Hi there!\"}}]}");
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
// A transport failure and its retry are part of the recorded truth.
|
|
try h.menu("chat.say.two");
|
|
try h.menu("chat.send");
|
|
try h.app_state.effects.feedResponseOutcome(chat_fetch_key, .timed_out, 0, "");
|
|
try h.wake();
|
|
try h.menu("chat.retry");
|
|
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"Certainly.\"}}]}");
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
recorder.finish();
|
|
try std.testing.expect(!recorder.failed);
|
|
return ChatSnapshot.take();
|
|
}
|
|
|
|
/// Replay the recorded journal into a fresh app launched with the given
|
|
/// env configuration. Its own function so each replay's multi-MB app
|
|
/// temporary lives in a transient frame, never stacked up in the test's.
|
|
fn replayWithEnv(journal_bytes: []const u8, env_values: []const Adapter.EnvValue) !runtime_ns.ReplayReport {
|
|
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
|
|
.size = geometry.SizeF.init(760, 640),
|
|
});
|
|
defer harness.destroy(std.testing.allocator);
|
|
harness.null_platform.gpu_surfaces = true;
|
|
const app_state = try std.testing.allocator.create(App);
|
|
defer std.testing.allocator.destroy(app_state);
|
|
app_state.* = Adapter.init(std.heap.page_allocator, .{ .env_values = env_values }, appOptions());
|
|
defer app_state.deinit();
|
|
return try runtime_ns.replaySession(&harness.runtime, app_state.app(), journal_bytes, .{
|
|
.verify = true,
|
|
.require_same_platform = false,
|
|
});
|
|
}
|
|
|
|
test "a recorded conversation replays byte-identically with zero host calls" {
|
|
const buffer = try std.heap.page_allocator.create(JournalBuffer);
|
|
defer std.heap.page_allocator.destroy(buffer);
|
|
buffer.len = 0;
|
|
const recorded = try recordSession(buffer);
|
|
try std.testing.expectEqual(@as(usize, 4), recorded.turns_len);
|
|
try std.testing.expect(recorded.phase == .idle);
|
|
try std.testing.expectEqualStrings("Certainly.", recorded.last_turn[0..recorded.last_turn_len]);
|
|
|
|
// Determinism pin: the same driven session records byte-identical
|
|
// journal bytes.
|
|
const second = try std.heap.page_allocator.create(JournalBuffer);
|
|
defer std.heap.page_allocator.destroy(second);
|
|
second.len = 0;
|
|
const recorded_again = try recordSession(second);
|
|
try std.testing.expectEqualDeep(recorded, recorded_again);
|
|
try std.testing.expectEqualSlices(u8, buffer.journalBytes(), second.journalBytes());
|
|
|
|
// Replay into a fresh app with the variables UNSET: the journaled
|
|
// fetch results feed the re-issued (parked) requests in recorded
|
|
// order — no endpoint, no network, no host calls — and the launch
|
|
// configuration feeds from the journal's `.env` records, so replay
|
|
// performs ZERO env reads. A machine with none of the variables set
|
|
// replays the recorded conversation byte-identically.
|
|
{
|
|
const report = try replayWithEnv(buffer.journalBytes(), &.{});
|
|
try std.testing.expect(report.ok());
|
|
try std.testing.expect(report.events_replayed > 0);
|
|
// The journaled effect results are the three fetch answers (two
|
|
// successes and the timeout) plus the three env deliveries.
|
|
try std.testing.expectEqual(@as(u64, 6), report.effects_fed);
|
|
try std.testing.expectEqualDeep(recorded, ChatSnapshot.take());
|
|
}
|
|
|
|
// Replay again with the variables CHANGED at replay launch: the
|
|
// journaled values still win (the recorded endpoint/model/key drive
|
|
// the replay, never the replay launch's environment) — the recorded
|
|
// truth is immune to the machine it replays on.
|
|
{
|
|
const changed_env = [_]Adapter.EnvValue{
|
|
.{ .msg = "endpoint_set", .value = "http://other.test/v2/chat" },
|
|
.{ .msg = "model_set", .value = "other-model" },
|
|
.{ .msg = "key_set", .value = "other-key" },
|
|
};
|
|
const report = try replayWithEnv(buffer.journalBytes(), &changed_env);
|
|
try std.testing.expect(report.ok());
|
|
try std.testing.expectEqual(@as(u64, 6), report.effects_fed);
|
|
try std.testing.expectEqualDeep(recorded, ChatSnapshot.take());
|
|
// The recorded configuration, not the changed launch's, is the
|
|
// replayed model's.
|
|
try std.testing.expectEqualStrings(test_model_name, Bridge.model().modelName);
|
|
try std.testing.expectEqualStrings(test_endpoint, Bridge.model().endpoint);
|
|
}
|
|
}
|