From efd313a7981b515db06dac0b2d462624e2da4168 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 25 May 2026 13:21:29 -0700 Subject: [PATCH 1/3] Add canvas E2E tests across all 5 SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1401 (per stephentoub's review comment) adding real-runtime E2E coverage for canvas SDK support in Rust, Node, Python, Go, and .NET. Each language gains 5 tests exercising the full host ↔ runtime ↔ provider loop against the bundled `copilot-cli` subprocess: 1. `canvas.open` round-trip — host-side `session.rpc.canvas.open` dispatches back to the declaring provider's `CanvasHandler`. 2. `canvas.action.invoke` round-trip — per-action handler is routed and its return value flows back to the caller verbatim. 3. `canvas.close` round-trip — handler's onClose is invoked. 4. `canvas_action_no_handler` — declaring an action without a handler surfaces the structured error code (where reachable). 5. `resumeSession` with `openCanvases` — rehydrated state is observable via the session's openCanvases accessor. No CAPI traffic is required; snapshots under `test/snapshots/canvas/` are empty (`conversations: []`) and the trigger is purely host-side RPC, so the suites are deterministic. .NET: `CanvasErrorHelpers.ToRpcException` now prefixes the error code in the message string, because the .NET JSON-RPC client doesn't surface the JSON-RPC `error.data` payload to callers — without this the structured code (e.g. `canvas_action_no_handler`) is unobservable on the receiving side. Matches how the Rust SDK already exposes canvas error codes. Refs https://github.com/github/copilot-sdk/pull/1401#discussion_r3295772902 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 2 +- dotnet/test/E2E/CanvasE2ETests.cs | 194 ++++++++ go/internal/e2e/canvas_e2e_test.go | 336 ++++++++++++++ nodejs/test/e2e/canvas.e2e.test.ts | 177 ++++++++ python/e2e/test_canvas_e2e.py | 205 +++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/canvas.rs | 426 ++++++++++++++++++ .../dispatches_canvas_action_invoke.yaml | 3 + ...tion_invoke_to_the_per_action_handler.yaml | 3 + .../canvas/dispatches_canvas_close.yaml | 3 + ...lose_to_the_provider_on_close_handler.yaml | 3 + ...close_to_the_provider_onclose_handler.yaml | 3 + .../canvas/dispatches_canvas_open.yaml | 3 + ...s_canvas_open_to_the_provider_handler.yaml | 3 + ...dispatchescanvasactioninvoketohandler.yaml | 3 + ...dispatchescanvasclosetoonclosehandler.yaml | 3 + ...dispatchescanvasopentoproviderhandler.yaml | 3 + ..._an_action_the_canvas_did_not_declare.yaml | 3 + .../returns_canvas_action_no_handler.yaml | 3 + ...r_when_declared_action_has_no_handler.yaml | 3 + ...en_the_declared_action_has_no_handler.yaml | 3 + ...andlerfordeclaredactionwithouthandler.yaml | 3 + .../canvas/seeds_open_canvases_on_resume.yaml | 3 + ...sume_from_the_runtime_resume_response.yaml | 3 + ...sume_from_the_runtime_resume_response.yaml | 3 + .../seedsopencanvasesonresumefromruntime.yaml | 3 + 26 files changed, 1398 insertions(+), 1 deletion(-) create mode 100644 dotnet/test/E2E/CanvasE2ETests.cs create mode 100644 go/internal/e2e/canvas_e2e_test.go create mode 100644 nodejs/test/e2e/canvas.e2e.test.ts create mode 100644 python/e2e/test_canvas_e2e.py create mode 100644 rust/tests/e2e/canvas.rs create mode 100644 test/snapshots/canvas/dispatches_canvas_action_invoke.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_close.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_open.yaml create mode 100644 test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml create mode 100644 test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml create mode 100644 test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml create mode 100644 test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml create mode 100644 test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml create mode 100644 test/snapshots/canvas/returns_canvas_action_no_handler.yaml create mode 100644 test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml create mode 100644 test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml create mode 100644 test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml create mode 100644 test/snapshots/canvas/seeds_open_canvases_on_resume.yaml create mode 100644 test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml create mode 100644 test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml create mode 100644 test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 6a6134e18..c6ad36bcf 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -203,7 +203,7 @@ public static LocalRpcInvocationException HandlerError(string message) => Build( "canvas_handler_error", message); - public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, error.Message); + public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, $"{error.Code}: {error.Message}"); private static LocalRpcInvocationException Build(string code, string message) { diff --git a/dotnet/test/E2E/CanvasE2ETests.cs b/dotnet/test/E2E/CanvasE2ETests.cs new file mode 100644 index 000000000..03a900c03 --- /dev/null +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class CanvasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "canvas", output) +{ + [Fact] + public async Task DispatchesCanvasOpenToProviderHandler() + { + var opens = new List(); + var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(opens: opens))); + + var result = await session.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-1", + input: new Dictionary { ["seed"] = 7 }); + + var open = Assert.Single(opens); + Assert.Equal("counter", open.CanvasId); + Assert.Equal("counter-1", open.InstanceId); + Assert.Equal(7, open.Input.GetProperty("seed").GetInt32()); + Assert.Equal("counter", result.CanvasId); + Assert.Equal("counter-1", result.InstanceId); + Assert.Equal("https://example.test/counter-1", result.Url); + Assert.Equal(CanvasInstanceAvailability.Ready, result.Availability); + } + + [Fact] + public async Task DispatchesCanvasActionInvokeToHandler() + { + var actions = new List(); + var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(actions: actions))); + + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-2"); + var result = await session.Rpc.Canvas.InvokeActionAsync( + instanceId: "counter-2", + actionName: "increment", + input: new Dictionary { ["amount"] = 3 }); + + var action = Assert.Single(actions); + Assert.Equal("counter", action.CanvasId); + Assert.Equal("counter-2", action.InstanceId); + Assert.Equal("increment", action.ActionName); + Assert.Equal(3, action.Input.GetProperty("amount").GetInt32()); + + Assert.NotNull(result.Result); + var payload = result.Result.Value; + Assert.True(payload.GetProperty("ok").GetBoolean()); + Assert.Equal("increment", payload.GetProperty("actionName").GetString()); + Assert.Equal(3, payload.GetProperty("input").GetProperty("amount").GetInt32()); + } + + [Fact] + public async Task DispatchesCanvasCloseToOnCloseHandler() + { + var closes = new List(); + var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(closes: closes))); + + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-3"); + await session.Rpc.Canvas.CloseAsync(instanceId: "counter-3"); + await Task.Delay(50); + + var close = Assert.Single(closes); + Assert.Equal("counter", close.CanvasId); + Assert.Equal("counter-3", close.InstanceId); + } + + [Fact] + public async Task ReturnsCanvasActionNoHandlerForDeclaredActionWithoutHandler() + { + var session = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + + await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-4"); + var ex = await Assert.ThrowsAsync(() => session.Rpc.Canvas.InvokeActionAsync( + instanceId: "counter-4", + actionName: "increment", + input: new Dictionary())); + + Assert.Contains("canvas_action_no_handler", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SeedsOpenCanvasesOnResumeFromRuntime() + { + var sessionA = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + + await sessionA.Rpc.Canvas.OpenAsync( + canvasId: "counter", + instanceId: "counter-resume", + input: new Dictionary { ["initial"] = true }); + + var resumed = await ResumeSessionAsync(sessionA.SessionId, CreateCanvasResumeConfig(new OpenOnlyCanvasHandler())); + + Assert.NotEmpty(resumed.OpenCanvases); + var match = Assert.Single(resumed.OpenCanvases, canvas => canvas.InstanceId == "counter-resume"); + Assert.Equal("counter", match.CanvasId); + } + + private static SessionConfig CreateCanvasSessionConfig(ICanvasHandler handler) => new() + { + Canvases = [CreateCounterCanvas()], + CanvasHandler = handler, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "counter-provider" }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }; + + private static ResumeSessionConfig CreateCanvasResumeConfig(ICanvasHandler handler) => new() + { + Canvases = [CreateCounterCanvas()], + CanvasHandler = handler, + RequestCanvasRenderer = true, + ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "counter-provider" }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }; + + private static CanvasDeclaration CreateCounterCanvas() => new() + { + Id = "counter", + DisplayName = "Counter", + Description = "A test counter canvas", + Actions = + [ + new CanvasAction + { + Name = "increment", + Description = "Increment the counter", + }, + ], + }; + + private class OpenOnlyCanvasHandler : CanvasHandlerBase + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + => Task.FromResult(new CanvasOpenResponse { Url = $"https://example.test/{context.InstanceId}" }); + } + + private sealed class RecordingCanvasHandler( + List? opens = null, + List? closes = null, + List? actions = null) : OpenOnlyCanvasHandler + { + public override Task OnOpenAsync(CanvasOpenContext context, CancellationToken cancellationToken) + { + opens?.Add(CloneOpenContext(context)); + return base.OnOpenAsync(context, cancellationToken); + } + + public override Task OnCloseAsync(CanvasLifecycleContext context, CancellationToken cancellationToken) + { + closes?.Add(context); + return Task.CompletedTask; + } + + public override Task OnActionAsync(CanvasActionContext context, CancellationToken cancellationToken) + { + actions?.Add(CloneActionContext(context)); + return Task.FromResult(new Dictionary + { + ["ok"] = true, + ["actionName"] = context.ActionName, + ["input"] = context.Input.Clone(), + }); + } + } + + private static CanvasOpenContext CloneOpenContext(CanvasOpenContext context) => new() + { + SessionId = context.SessionId, + ExtensionId = context.ExtensionId, + CanvasId = context.CanvasId, + InstanceId = context.InstanceId, + Input = context.Input.Clone(), + Host = context.Host, + }; + + private static CanvasActionContext CloneActionContext(CanvasActionContext context) => new() + { + SessionId = context.SessionId, + ExtensionId = context.ExtensionId, + CanvasId = context.CanvasId, + InstanceId = context.InstanceId, + ActionName = context.ActionName, + Input = context.Input.Clone(), + Host = context.Host, + }; +} diff --git a/go/internal/e2e/canvas_e2e_test.go b/go/internal/e2e/canvas_e2e_test.go new file mode 100644 index 000000000..4950732b0 --- /dev/null +++ b/go/internal/e2e/canvas_e2e_test.go @@ -0,0 +1,336 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestCanvasE2E(t *testing.T) { + t.Run("dispatches_canvas_open", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + + result, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ + CanvasID: "counter", + InstanceID: "counter-1", + Input: json.RawMessage(`{"seed":7}`), + }) + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + + opens := handler.openCallsSnapshot() + if len(opens) != 1 { + t.Fatalf("expected 1 OnOpen call, got %d", len(opens)) + } + assertOpenCall(t, opens[0], "counter", "counter-1", `{"seed":7}`) + assertOpenCanvasInstance(t, result, "counter", "counter-1", "https://example.test/counter-1") + }) + + t.Run("dispatches_canvas_action_invoke", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + openCanvas(t, session, "counter-2", nil) + + result, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ + InstanceID: "counter-2", + ActionName: "increment", + Input: json.RawMessage(`{"amount":3}`), + }) + if err != nil { + t.Fatalf("Canvas.InvokeAction failed: %v", err) + } + + actions := handler.actionCallsSnapshot() + if len(actions) != 1 { + t.Fatalf("expected 1 OnAction call, got %d", len(actions)) + } + if actions[0].CanvasID != "counter" { + t.Errorf("expected canvasId counter, got %q", actions[0].CanvasID) + } + if actions[0].InstanceID != "counter-2" { + t.Errorf("expected instanceId counter-2, got %q", actions[0].InstanceID) + } + if actions[0].ActionName != "increment" { + t.Errorf("expected actionName increment, got %q", actions[0].ActionName) + } + assertJSONValue(t, actions[0].Input, `{"amount":3}`) + assertJSONValue(t, result.Result, `{"ok":true,"actionName":"increment","input":{"amount":3}}`) + }) + + t.Run("dispatches_canvas_close", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + handler := &recordingCanvasE2EHandler{} + session := createCanvasSession(t, client, ctx, handler) + openCanvas(t, session, "counter-3", nil) + + if _, err := session.RPC.Canvas.Close(t.Context(), &rpc.CanvasCloseRequest{InstanceID: "counter-3"}); err != nil { + t.Fatalf("Canvas.Close failed: %v", err) + } + + time.Sleep(50 * time.Millisecond) + closes := handler.closeCallsSnapshot() + if len(closes) != 1 { + t.Fatalf("expected 1 OnClose call, got %d", len(closes)) + } + if closes[0].CanvasID != "counter" { + t.Errorf("expected canvasId counter, got %q", closes[0].CanvasID) + } + if closes[0].InstanceID != "counter-3" { + t.Errorf("expected instanceId counter-3, got %q", closes[0].InstanceID) + } + }) + + t.Run("returns_canvas_action_no_handler", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + session := createCanvasSession(t, client, ctx, &openOnlyCanvasE2EHandler{}) + openCanvas(t, session, "counter-4", nil) + + _, err := session.RPC.Canvas.InvokeAction(t.Context(), &rpc.CanvasInvokeActionRequest{ + InstanceID: "counter-4", + ActionName: "increment", + Input: json.RawMessage(`{}`), + }) + if err == nil { + t.Fatalf("expected Canvas.InvokeAction to fail") + } + assertJSONRPCErrorCode(t, err, "canvas_action_no_handler") + }) + + t.Run("seeds_open_canvases_on_resume", func(t *testing.T) { + ctx := newCanvasTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + sessionA := createCanvasSession(t, client, ctx, &recordingCanvasE2EHandler{}) + openCanvas(t, sessionA, "counter-resume", json.RawMessage(`{"initial":true}`)) + + resumed, err := client.ResumeSession(t.Context(), sessionA.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + Canvases: counterCanvasDeclarations(), + CanvasHandler: &recordingCanvasE2EHandler{}, + RequestCanvasRenderer: copilot.Bool(true), + ExtensionInfo: counterExtensionInfo(), + }) + if err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + + seeded := resumed.OpenCanvases() + if len(seeded) == 0 { + t.Fatalf("expected resumed OpenCanvases to contain entries") + } + for _, canvas := range seeded { + if canvas.InstanceID == "counter-resume" { + if canvas.CanvasID != "counter" { + t.Fatalf("expected resumed canvasId counter, got %q", canvas.CanvasID) + } + return + } + } + t.Fatalf("expected resumed OpenCanvases to include counter-resume, got %+v", seeded) + }) +} + +type recordingCanvasE2EHandler struct { + copilot.CanvasHandlerDefaults + + mu sync.Mutex + openCalls []copilot.CanvasOpenContext + closeCalls []copilot.CanvasLifecycleContext + actionCalls []copilot.CanvasActionContext +} + +func (h *recordingCanvasE2EHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { + h.mu.Lock() + h.openCalls = append(h.openCalls, c) + h.mu.Unlock() + url := fmt.Sprintf("https://example.test/%s", c.InstanceID) + return copilot.CanvasOpenResponse{URL: &url}, nil +} + +func (h *recordingCanvasE2EHandler) OnClose(ctx context.Context, c copilot.CanvasLifecycleContext) error { + h.mu.Lock() + h.closeCalls = append(h.closeCalls, c) + h.mu.Unlock() + return nil +} + +func (h *recordingCanvasE2EHandler) OnAction(ctx context.Context, c copilot.CanvasActionContext) (any, error) { + h.mu.Lock() + h.actionCalls = append(h.actionCalls, c) + h.mu.Unlock() + return map[string]any{"ok": true, "actionName": c.ActionName, "input": c.Input}, nil +} + +func (h *recordingCanvasE2EHandler) openCallsSnapshot() []copilot.CanvasOpenContext { + h.mu.Lock() + defer h.mu.Unlock() + return append([]copilot.CanvasOpenContext(nil), h.openCalls...) +} + +func (h *recordingCanvasE2EHandler) closeCallsSnapshot() []copilot.CanvasLifecycleContext { + h.mu.Lock() + defer h.mu.Unlock() + return append([]copilot.CanvasLifecycleContext(nil), h.closeCalls...) +} + +func (h *recordingCanvasE2EHandler) actionCallsSnapshot() []copilot.CanvasActionContext { + h.mu.Lock() + defer h.mu.Unlock() + return append([]copilot.CanvasActionContext(nil), h.actionCalls...) +} + +type openOnlyCanvasE2EHandler struct { + copilot.CanvasHandlerDefaults +} + +func (h *openOnlyCanvasE2EHandler) OnOpen(ctx context.Context, c copilot.CanvasOpenContext) (copilot.CanvasOpenResponse, error) { + url := fmt.Sprintf("https://example.test/%s", c.InstanceID) + return copilot.CanvasOpenResponse{URL: &url}, nil +} + +func newCanvasTestContext(t *testing.T) *testharness.TestContext { + t.Helper() + ctx := testharness.NewTestContext(t) + ctx.ConfigureForTest(t) + return ctx +} + +func createCanvasSession(t *testing.T, client *copilot.Client, ctx *testharness.TestContext, handler copilot.CanvasHandler) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + Canvases: counterCanvasDeclarations(), + CanvasHandler: handler, + RequestCanvasRenderer: copilot.Bool(true), + ExtensionInfo: counterExtensionInfo(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} + +func counterCanvasDeclarations() []copilot.CanvasDeclaration { + description := "Increment the counter" + return []copilot.CanvasDeclaration{ + { + ID: "counter", + DisplayName: "Counter", + Description: "A test counter canvas", + Actions: []rpc.CanvasAction{ + {Name: "increment", Description: &description}, + }, + }, + } +} + +func counterExtensionInfo() *copilot.ExtensionInfo { + return &copilot.ExtensionInfo{Source: "github-app", Name: "counter-provider"} +} + +func openCanvas(t *testing.T, session *copilot.Session, instanceID string, input any) *rpc.OpenCanvasInstance { + t.Helper() + result, err := session.RPC.Canvas.Open(t.Context(), &rpc.CanvasOpenRequest{ + CanvasID: "counter", + InstanceID: instanceID, + Input: input, + }) + if err != nil { + t.Fatalf("Canvas.Open failed: %v", err) + } + return result +} + +func assertOpenCall(t *testing.T, got copilot.CanvasOpenContext, canvasID, instanceID, input string) { + t.Helper() + if got.CanvasID != canvasID { + t.Errorf("expected canvasId %q, got %q", canvasID, got.CanvasID) + } + if got.InstanceID != instanceID { + t.Errorf("expected instanceId %q, got %q", instanceID, got.InstanceID) + } + assertJSONValue(t, got.Input, input) +} + +func assertOpenCanvasInstance(t *testing.T, got *rpc.OpenCanvasInstance, canvasID, instanceID, url string) { + t.Helper() + if got == nil { + t.Fatalf("expected non-nil OpenCanvasInstance") + } + if got.CanvasID != canvasID { + t.Errorf("expected canvasId %q, got %q", canvasID, got.CanvasID) + } + if got.InstanceID != instanceID { + t.Errorf("expected instanceId %q, got %q", instanceID, got.InstanceID) + } + if got.URL == nil || *got.URL != url { + t.Errorf("expected url %q, got %v", url, got.URL) + } + if got.Availability != rpc.CanvasInstanceAvailabilityReady { + t.Errorf("expected availability ready, got %q", got.Availability) + } +} + +func assertJSONValue(t *testing.T, got any, wantJSON string) { + t.Helper() + var want any + if err := json.Unmarshal([]byte(wantJSON), &want); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %v", err) + } + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("failed to marshal actual value: %v", err) + } + var normalizedGot any + if err := json.Unmarshal(gotJSON, &normalizedGot); err != nil { + t.Fatalf("failed to normalize actual JSON %s: %v", gotJSON, err) + } + if !reflect.DeepEqual(normalizedGot, want) { + t.Fatalf("JSON mismatch: got %s, want %s", gotJSON, wantJSON) + } +} + +func assertJSONRPCErrorCode(t *testing.T, err error, wantCode string) { + t.Helper() + rpcErr, ok := err.(*jsonrpc2.Error) + if !ok { + t.Fatalf("expected *jsonrpc2.Error, got %T: %v", err, err) + } + var data struct { + Code string `json:"code"` + } + if unmarshalErr := json.Unmarshal(rpcErr.Data, &data); unmarshalErr != nil { + t.Fatalf("failed to unmarshal JSON-RPC error data %s: %v", rpcErr.Data, unmarshalErr) + } + if data.Code != wantCode { + t.Fatalf("expected error code %q, got %q (error: %v)", wantCode, data.Code, err) + } +} diff --git a/nodejs/test/e2e/canvas.e2e.test.ts b/nodejs/test/e2e/canvas.e2e.test.ts new file mode 100644 index 000000000..b2d280978 --- /dev/null +++ b/nodejs/test/e2e/canvas.e2e.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll, createCanvas } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +// E2E coverage for the canvas SDK ↔ runtime loop. The host-side +// `session.rpc.canvas.{open,close,invokeAction}` RPCs drive the runtime to +// dispatch `canvas.open` / `canvas.close` / `canvas.action.invoke` back to the +// declaring provider (us). These tests do not involve CAPI, so their +// snapshots are empty (`conversations: []`). +describe("Canvas E2E", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + function makeCounter(record: { + open?: { instanceId: string; canvasId: string; input?: unknown }[]; + close?: { instanceId: string; canvasId: string }[]; + action?: { actionName: string; instanceId: string; input?: unknown }[]; + }) { + return createCanvas({ + id: "counter", + displayName: "Counter", + description: "A test counter canvas", + actions: [ + { + name: "increment", + description: "Increment the counter", + handler: ({ actionName, instanceId, input }) => { + record.action?.push({ actionName, instanceId, input }); + return { ok: true, actionName, input }; + }, + }, + ], + open: ({ instanceId, canvasId, input }) => { + record.open?.push({ instanceId, canvasId, input }); + return { url: `https://example.test/${instanceId}` }; + }, + onClose: ({ instanceId, canvasId }) => { + record.close?.push({ instanceId, canvasId }); + }, + }); + } + + it("dispatches canvas.open to the provider handler", async () => { + const opens: { instanceId: string; canvasId: string; input?: unknown }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({ open: opens })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + const result = await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-1", + input: { seed: 7 }, + }); + + expect(opens).toEqual([ + { instanceId: "counter-1", canvasId: "counter", input: { seed: 7 } }, + ]); + expect(result).toMatchObject({ + instanceId: "counter-1", + canvasId: "counter", + url: "https://example.test/counter-1", + availability: "ready", + }); + }); + + it("dispatches canvas.action.invoke to the per-action handler", async () => { + const actions: { actionName: string; instanceId: string; input?: unknown }[] = []; + const opens: { instanceId: string; canvasId: string; input?: unknown }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({ open: opens, action: actions })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-2" }); + + const result = await session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-2", + actionName: "increment", + input: { amount: 3 }, + }); + + expect(actions).toEqual([ + { + actionName: "increment", + instanceId: "counter-2", + input: { amount: 3 }, + }, + ]); + expect(result).toEqual({ + result: { ok: true, actionName: "increment", input: { amount: 3 } }, + }); + }); + + it("dispatches canvas.close to the provider onClose handler", async () => { + const closes: { instanceId: string; canvasId: string }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({ close: closes })], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-3" }); + await session.rpc.canvas.close({ canvasId: "counter", instanceId: "counter-3" }); + + // onClose is fire-and-forget on the runtime side; allow a microtask flush. + await new Promise((r) => setTimeout(r, 50)); + + expect(closes).toEqual([{ instanceId: "counter-3", canvasId: "counter" }]); + }); + + it("rejects invokeAction for an action the canvas did not declare", async () => { + // The Node `createCanvas` API requires every declared action to ship + // with a `handler`, so the `canvas_action_no_handler` SDK-internal + // error is unreachable via the runtime path here — it's covered by + // unit tests in `client.test.ts`. The runtime, however, pre-validates + // action names against the declaration before dispatching, and that + // user-visible rejection is what we exercise end-to-end. + const session = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-4" }); + + await expect( + session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-4", + actionName: "ghost", + input: {}, + }) + ).rejects.toThrow(/Unknown action "ghost"/); + }); + + it("seeds openCanvases on resume from the runtime resume response", async () => { + // Open a canvas in session A, then resume into a fresh session view + // and assert the resumed view's openCanvases() reflects the live + // instance reported by the runtime. + const sessionA = await client.createSession({ + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + await sessionA.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-resume", + input: { initial: true }, + }); + + const resumed = await client.resumeSession(sessionA.sessionId, { + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + const seeded = resumed.openCanvases; + expect(seeded.length).toBeGreaterThan(0); + const match = seeded.find((c) => c.instanceId === "counter-resume"); + expect(match).toBeDefined(); + expect(match?.canvasId).toBe("counter"); + }); +}); diff --git a/python/e2e/test_canvas_e2e.py b/python/e2e/test_canvas_e2e.py new file mode 100644 index 000000000..db4b224d1 --- /dev/null +++ b/python/e2e/test_canvas_e2e.py @@ -0,0 +1,205 @@ +"""E2E coverage for canvas runtime dispatch.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from copilot import ( + CanvasAction, + CanvasActionContext, + CanvasDeclaration, + CanvasHandler, + CanvasLifecycleContext, + CanvasOpenContext, + CanvasOpenResponse, + ExtensionInfo, +) +from copilot._jsonrpc import JsonRpcError +from copilot.generated.rpc import ( + CanvasCloseRequest, + CanvasInstanceAvailability, + CanvasInvokeActionRequest, + CanvasOpenRequest, +) + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +_EXTENSION_INFO = ExtensionInfo(source="github-app", name="counter-provider") + + +def _counter_declaration(*, actions: list[CanvasAction] | None = None) -> CanvasDeclaration: + return CanvasDeclaration( + id="counter", + display_name="Counter", + description="A test counter canvas", + actions=actions, + ) + + +class _CounterHandler(CanvasHandler): + def __init__(self) -> None: + self.opens: list[CanvasOpenContext] = [] + self.closes: list[CanvasLifecycleContext] = [] + self.actions: list[CanvasActionContext] = [] + + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + self.opens.append(ctx) + return CanvasOpenResponse(url=f"https://example.test/{ctx.instance_id}") + + async def on_close(self, ctx: CanvasLifecycleContext) -> None: + self.closes.append(ctx) + + async def on_action(self, ctx: CanvasActionContext) -> Any: + self.actions.append(ctx) + return {"ok": True, "actionName": ctx.action_name, "input": ctx.input} + + +class _NoActionHandler(CanvasHandler): + async def on_open(self, ctx: CanvasOpenContext) -> CanvasOpenResponse: + return CanvasOpenResponse(url=f"https://example.test/{ctx.instance_id}") + + +async def _create_counter_session( + ctx: E2ETestContext, + handler: CanvasHandler, + *, + actions: list[CanvasAction] | None = None, +): + return await ctx.client.create_session( + canvases=[_counter_declaration(actions=actions)], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, + canvas_handler=handler, + ) + + +class TestCanvas: + async def test_dispatches_canvas_open_to_the_provider_handler(self, ctx: E2ETestContext): + handler = _CounterHandler() + session = await _create_counter_session(ctx, handler) + + result = await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-1", + input={"seed": 7}, + ) + ) + + assert len(handler.opens) == 1 + opened = handler.opens[0] + assert opened.canvas_id == "counter" + assert opened.instance_id == "counter-1" + assert opened.input == {"seed": 7} + assert result.canvas_id == "counter" + assert result.instance_id == "counter-1" + assert result.url == "https://example.test/counter-1" + assert result.availability == CanvasInstanceAvailability.READY + + async def test_dispatches_canvas_action_invoke_to_the_per_action_handler( + self, ctx: E2ETestContext + ): + handler = _CounterHandler() + session = await _create_counter_session( + ctx, + handler, + actions=[CanvasAction(name="increment", description="Increment the counter")], + ) + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-2") + ) + + result = await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-2", + input={"amount": 3}, + ) + ) + + assert len(handler.actions) == 1 + action = handler.actions[0] + assert action.canvas_id == "counter" + assert action.instance_id == "counter-2" + assert action.action_name == "increment" + assert action.input == {"amount": 3} + assert result.result == { + "ok": True, + "actionName": "increment", + "input": {"amount": 3}, + } + + async def test_dispatches_canvas_close_to_the_provider_on_close_handler( + self, ctx: E2ETestContext + ): + handler = _CounterHandler() + session = await _create_counter_session(ctx, handler) + + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-3") + ) + await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) + await asyncio.sleep(0.05) + + assert len(handler.closes) == 1 + closed = handler.closes[0] + assert closed.canvas_id == "counter" + assert closed.instance_id == "counter-3" + + async def test_returns_canvas_action_no_handler_when_declared_action_has_no_handler( + self, ctx: E2ETestContext + ): + session = await _create_counter_session( + ctx, + _NoActionHandler(), + actions=[CanvasAction(name="increment", description="Increment the counter")], + ) + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-4") + ) + + with pytest.raises(JsonRpcError) as excinfo: + await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-4", + input={}, + ) + ) + + assert excinfo.value.data == { + "code": "canvas_action_no_handler", + "message": "No handler implemented for this canvas action", + } + + async def test_seeds_open_canvases_on_resume_from_the_runtime_resume_response( + self, ctx: E2ETestContext + ): + session_a = await _create_counter_session(ctx, _CounterHandler()) + await session_a.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-resume", + input={"initial": True}, + ) + ) + + resumed = await ctx.client.resume_session( + session_a.session_id, + canvases=[_counter_declaration()], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, + canvas_handler=_CounterHandler(), + ) + + matching = [ + canvas for canvas in resumed.open_canvases if canvas.instance_id == "counter-resume" + ] + assert len(matching) == 1 + assert matching[0].canvas_id == "counter" diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 09ece6cf5..12863aff4 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -7,6 +7,8 @@ mod abort; mod ask_user; #[path = "e2e/builtin_tools.rs"] mod builtin_tools; +#[path = "e2e/canvas.rs"] +mod canvas; #[path = "e2e/client.rs"] mod client; #[path = "e2e/client_api.rs"] diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs new file mode 100644 index 000000000..86ec2b235 --- /dev/null +++ b/rust/tests/e2e/canvas.rs @@ -0,0 +1,426 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use github_copilot_sdk::Error; +use github_copilot_sdk::canvas::{ + CanvasDeclaration, CanvasHandler, CanvasOpenContext, CanvasOpenResponse, CanvasResult, +}; +use github_copilot_sdk::generated::api_types::{ + CanvasAction, CanvasCloseRequest, CanvasInstanceAvailability, CanvasInvokeActionRequest, + CanvasOpenRequest, +}; +use github_copilot_sdk::types::{ExtensionInfo, ResumeSessionConfig}; +use parking_lot::Mutex; +use serde_json::{Value, json}; + +use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; + +#[derive(Debug, PartialEq)] +struct OpenCall { + canvas_id: String, + instance_id: String, + input: Value, +} + +#[derive(Debug, PartialEq)] +struct ActionCall { + action_name: String, + instance_id: String, + input: Value, +} + +#[derive(Debug, PartialEq)] +struct CloseCall { + canvas_id: String, + instance_id: String, +} + +#[derive(Default)] +struct CanvasCalls { + opens: Mutex>, + actions: Mutex>, + closes: Mutex>, +} + +struct CounterHandler { + calls: Arc, +} + +#[async_trait] +impl CanvasHandler for CounterHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + record_open(&self.calls, &ctx); + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.instance_id)), + title: None, + status: None, + }) + } + + async fn on_action( + &self, + ctx: github_copilot_sdk::canvas::CanvasActionContext, + ) -> CanvasResult { + self.calls.actions.lock().push(ActionCall { + action_name: ctx.action_name.clone(), + instance_id: ctx.instance_id, + input: ctx.input.clone(), + }); + Ok(json!({ + "ok": true, + "actionName": ctx.action_name, + "input": ctx.input, + })) + } + + async fn on_close( + &self, + ctx: github_copilot_sdk::canvas::CanvasLifecycleContext, + ) -> CanvasResult<()> { + self.calls.closes.lock().push(CloseCall { + canvas_id: ctx.canvas_id, + instance_id: ctx.instance_id, + }); + Ok(()) + } +} + +struct OpenOnlyHandler { + calls: Arc, +} + +#[async_trait] +impl CanvasHandler for OpenOnlyHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + record_open(&self.calls, &ctx); + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.instance_id)), + title: None, + status: None, + }) + } +} + +#[tokio::test] +async fn dispatches_canvas_open_to_the_provider_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_open_to_the_provider_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + let result = session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: Some(json!({ "seed": 7 })), + instance_id: "counter-1".to_string(), + }) + .await + .expect("open canvas"); + + assert_eq!( + calls.opens.lock().as_slice(), + [OpenCall { + canvas_id: "counter".to_string(), + instance_id: "counter-1".to_string(), + input: json!({ "seed": 7 }), + }] + ); + assert_eq!(result.canvas_id, "counter"); + assert_eq!(result.instance_id, "counter-1"); + assert_eq!( + result.url.as_deref(), + Some("https://example.test/counter-1") + ); + assert_eq!(result.availability, CanvasInstanceAvailability::Ready); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn dispatches_canvas_action_invoke_to_the_per_action_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_action_invoke_to_the_per_action_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: None, + instance_id: "counter-2".to_string(), + }) + .await + .expect("open canvas"); + let result = session + .rpc() + .canvas() + .invoke_action(CanvasInvokeActionRequest { + action_name: "increment".to_string(), + input: Some(json!({ "amount": 3 })), + instance_id: "counter-2".to_string(), + }) + .await + .expect("invoke action"); + + assert_eq!( + calls.actions.lock().as_slice(), + [ActionCall { + action_name: "increment".to_string(), + instance_id: "counter-2".to_string(), + input: json!({ "amount": 3 }), + }] + ); + assert_eq!( + result.result, + Some(json!({ + "ok": true, + "actionName": "increment", + "input": { "amount": 3 }, + })) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn dispatches_canvas_close_to_the_provider_on_close_handler() { + with_e2e_context( + "canvas", + "dispatches_canvas_close_to_the_provider_on_close_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let calls = Arc::new(CanvasCalls::default()); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: calls.clone(), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: None, + instance_id: "counter-3".to_string(), + }) + .await + .expect("open canvas"); + session + .rpc() + .canvas() + .close(CanvasCloseRequest { + instance_id: "counter-3".to_string(), + }) + .await + .expect("close canvas"); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert_eq!( + calls.closes.lock().as_slice(), + [CloseCall { + canvas_id: "counter".to_string(), + instance_id: "counter-3".to_string(), + }] + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn returns_canvas_action_no_handler_when_the_declared_action_has_no_handler() { + with_e2e_context( + "canvas", + "returns_canvas_action_no_handler_when_the_declared_action_has_no_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(OpenOnlyHandler { + calls: Arc::new(CanvasCalls::default()), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: None, + instance_id: "counter-4".to_string(), + }) + .await + .expect("open canvas"); + let err = session + .rpc() + .canvas() + .invoke_action(CanvasInvokeActionRequest { + action_name: "increment".to_string(), + input: Some(json!({})), + instance_id: "counter-4".to_string(), + }) + .await + .expect_err("invoke action should fail"); + + assert_rpc_error_contains(&err, "No handler implemented for this canvas action"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn seeds_open_canvases_on_resume_from_the_runtime_resume_response() { + with_e2e_context( + "canvas", + "seeds_open_canvases_on_resume_from_the_runtime_resume_response", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(canvas_session_config(Arc::new(CounterHandler { + calls: Arc::new(CanvasCalls::default()), + }))) + .await + .expect("create session"); + + session + .rpc() + .canvas() + .open(CanvasOpenRequest { + canvas_id: "counter".to_string(), + extension_id: None, + input: Some(json!({ "initial": true })), + instance_id: "counter-resume".to_string(), + }) + .await + .expect("open canvas"); + + let resumed = client + .resume_session( + ResumeSessionConfig::new(session.id().clone()) + .with_canvases([counter_canvas()]) + .with_canvas_handler(Arc::new(CounterHandler { + calls: Arc::new(CanvasCalls::default()), + })) + .with_request_canvas_renderer(true) + .with_extension_info(extension_info()) + .with_github_token(DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + + let seeded = resumed.open_canvases(); + assert!( + seeded + .iter() + .any(|canvas| canvas.instance_id == "counter-resume" + && canvas.canvas_id == "counter") + ); + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + session.stop_event_loop().await; + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn record_open(calls: &CanvasCalls, ctx: &CanvasOpenContext) { + calls.opens.lock().push(OpenCall { + canvas_id: ctx.canvas_id.clone(), + instance_id: ctx.instance_id.clone(), + input: ctx.input.clone(), + }); +} + +fn canvas_session_config(handler: Arc) -> github_copilot_sdk::SessionConfig { + github_copilot_sdk::SessionConfig::default() + .with_permission_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_canvases([counter_canvas()]) + .with_canvas_handler(handler) + .with_request_canvas_renderer(true) + .with_extension_info(extension_info()) +} + +fn counter_canvas() -> CanvasDeclaration { + let mut canvas = CanvasDeclaration::new("counter", "Counter", "A test counter canvas"); + canvas.actions = Some(vec![CanvasAction { + name: "increment".to_string(), + description: Some("Increment the counter".to_string()), + input_schema: None, + }]); + canvas +} + +fn extension_info() -> ExtensionInfo { + ExtensionInfo::new("github-app", "counter-provider") +} + +fn assert_rpc_error_contains(err: &Error, expected: &str) { + match err { + Error::Rpc { message, .. } => assert!( + message.contains(expected), + "expected RPC error message to contain {expected:?}, got {message:?}" + ), + other => panic!("expected RPC error, got {other:?}"), + } +} diff --git a/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml b/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_action_invoke.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml b/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_action_invoke_to_the_per_action_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close.yaml b/test/snapshots/canvas/dispatches_canvas_close.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_on_close_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_close_to_the_provider_onclose_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_open.yaml b/test/snapshots/canvas/dispatches_canvas_open.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_open.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml b/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatches_canvas_open_to_the_provider_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml b/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasactioninvoketohandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml b/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasclosetoonclosehandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml b/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/dispatchescanvasopentoproviderhandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml b/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/rejects_invokeaction_for_an_action_the_canvas_did_not_declare.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler_when_declared_action_has_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml b/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returns_canvas_action_no_handler_when_the_declared_action_has_no_handler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml b/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/returnscanvasactionnohandlerfordeclaredactionwithouthandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml b/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_open_canvases_on_resume.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml b/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_open_canvases_on_resume_from_the_runtime_resume_response.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml b/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seeds_opencanvases_on_resume_from_the_runtime_resume_response.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml b/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/canvas/seedsopencanvasesonresumefromruntime.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] From 0e5b2372f0fee09866baf4ee57c36bfd88ef7b21 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 25 May 2026 13:22:58 -0700 Subject: [PATCH 2/3] Add TODO comment explaining ToRpcException code prefix Per review feedback, document why the canvas error code is prefixed into the message string so future maintainers know the long-term fix is to plumb error.data through RemoteRpcException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index c6ad36bcf..b0360056b 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -203,6 +203,10 @@ public static LocalRpcInvocationException HandlerError(string message) => Build( "canvas_handler_error", message); + // Code is prefixed into the message because RemoteRpcException does not currently + // surface the JSON-RPC error.data payload to callers, so the structured code (e.g. + // "canvas_action_no_handler") would otherwise be unobservable on the receiving side. + // TODO: plumb error.data through RemoteRpcException and drop the prefix here. public static LocalRpcInvocationException ToRpcException(CanvasError error) => Build(error.Code, $"{error.Code}: {error.Message}"); private static LocalRpcInvocationException Build(string code, string message) From 3df561a3ced96c1a3e2840c5369d16992f3c4d56 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 25 May 2026 20:26:07 -0700 Subject: [PATCH 3/3] Dispose canvas E2E sessions and fix .NET nullable warning Address review feedback on PR #1422: - Dispose every created/resumed session so the shared E2E client doesn't leak runtime resources across tests and so per-test workdir cleanup is not racing live sessions: - .NET: `await using var session = ...` for create + resume - Node: `try/finally` with `await session.disconnect()` - Python: `try/finally` with `await session.disconnect()` - Go: `t.Cleanup(func() { _ = session.Disconnect() })` inside the shared `createCanvasSession` helper and on the resumed session - .NET: extract `result.Result` into a local and use the null-forgiving operator so the nullable dereference warning at line 53 is silenced without changing behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/CanvasE2ETests.cs | 17 +-- go/internal/e2e/canvas_e2e_test.go | 2 + nodejs/test/e2e/canvas.e2e.test.ts | 152 ++++++++++++++----------- python/e2e/test_canvas_e2e.py | 172 ++++++++++++++++------------- 4 files changed, 195 insertions(+), 148 deletions(-) diff --git a/dotnet/test/E2E/CanvasE2ETests.cs b/dotnet/test/E2E/CanvasE2ETests.cs index 03a900c03..d451e4609 100644 --- a/dotnet/test/E2E/CanvasE2ETests.cs +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -15,7 +15,7 @@ public class CanvasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : public async Task DispatchesCanvasOpenToProviderHandler() { var opens = new List(); - var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(opens: opens))); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(opens: opens))); var result = await session.Rpc.Canvas.OpenAsync( canvasId: "counter", @@ -36,7 +36,7 @@ public async Task DispatchesCanvasOpenToProviderHandler() public async Task DispatchesCanvasActionInvokeToHandler() { var actions = new List(); - var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(actions: actions))); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(actions: actions))); await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-2"); var result = await session.Rpc.Canvas.InvokeActionAsync( @@ -50,8 +50,9 @@ public async Task DispatchesCanvasActionInvokeToHandler() Assert.Equal("increment", action.ActionName); Assert.Equal(3, action.Input.GetProperty("amount").GetInt32()); - Assert.NotNull(result.Result); - var payload = result.Result.Value; + var actionResult = result.Result; + Assert.NotNull(actionResult); + var payload = actionResult!.Value; Assert.True(payload.GetProperty("ok").GetBoolean()); Assert.Equal("increment", payload.GetProperty("actionName").GetString()); Assert.Equal(3, payload.GetProperty("input").GetProperty("amount").GetInt32()); @@ -61,7 +62,7 @@ public async Task DispatchesCanvasActionInvokeToHandler() public async Task DispatchesCanvasCloseToOnCloseHandler() { var closes = new List(); - var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(closes: closes))); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new RecordingCanvasHandler(closes: closes))); await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-3"); await session.Rpc.Canvas.CloseAsync(instanceId: "counter-3"); @@ -75,7 +76,7 @@ public async Task DispatchesCanvasCloseToOnCloseHandler() [Fact] public async Task ReturnsCanvasActionNoHandlerForDeclaredActionWithoutHandler() { - var session = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + await using var session = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); await session.Rpc.Canvas.OpenAsync(canvasId: "counter", instanceId: "counter-4"); var ex = await Assert.ThrowsAsync(() => session.Rpc.Canvas.InvokeActionAsync( @@ -89,14 +90,14 @@ public async Task ReturnsCanvasActionNoHandlerForDeclaredActionWithoutHandler() [Fact] public async Task SeedsOpenCanvasesOnResumeFromRuntime() { - var sessionA = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); + await using var sessionA = await CreateSessionAsync(CreateCanvasSessionConfig(new OpenOnlyCanvasHandler())); await sessionA.Rpc.Canvas.OpenAsync( canvasId: "counter", instanceId: "counter-resume", input: new Dictionary { ["initial"] = true }); - var resumed = await ResumeSessionAsync(sessionA.SessionId, CreateCanvasResumeConfig(new OpenOnlyCanvasHandler())); + await using var resumed = await ResumeSessionAsync(sessionA.SessionId, CreateCanvasResumeConfig(new OpenOnlyCanvasHandler())); Assert.NotEmpty(resumed.OpenCanvases); var match = Assert.Single(resumed.OpenCanvases, canvas => canvas.InstanceId == "counter-resume"); diff --git a/go/internal/e2e/canvas_e2e_test.go b/go/internal/e2e/canvas_e2e_test.go index 4950732b0..bbd91084b 100644 --- a/go/internal/e2e/canvas_e2e_test.go +++ b/go/internal/e2e/canvas_e2e_test.go @@ -140,6 +140,7 @@ func TestCanvasE2E(t *testing.T) { if err != nil { t.Fatalf("ResumeSession failed: %v", err) } + t.Cleanup(func() { _ = resumed.Disconnect() }) seeded := resumed.OpenCanvases() if len(seeded) == 0 { @@ -235,6 +236,7 @@ func createCanvasSession(t *testing.T, client *copilot.Client, ctx *testharness. if err != nil { t.Fatalf("CreateSession failed: %v", err) } + t.Cleanup(func() { _ = session.Disconnect() }) return session } diff --git a/nodejs/test/e2e/canvas.e2e.test.ts b/nodejs/test/e2e/canvas.e2e.test.ts index b2d280978..eea107412 100644 --- a/nodejs/test/e2e/canvas.e2e.test.ts +++ b/nodejs/test/e2e/canvas.e2e.test.ts @@ -52,21 +52,25 @@ describe("Canvas E2E", async () => { extensionInfo: { source: "github-app", name: "counter-provider" }, }); - const result = await session.rpc.canvas.open({ - canvasId: "counter", - instanceId: "counter-1", - input: { seed: 7 }, - }); - - expect(opens).toEqual([ - { instanceId: "counter-1", canvasId: "counter", input: { seed: 7 } }, - ]); - expect(result).toMatchObject({ - instanceId: "counter-1", - canvasId: "counter", - url: "https://example.test/counter-1", - availability: "ready", - }); + try { + const result = await session.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-1", + input: { seed: 7 }, + }); + + expect(opens).toEqual([ + { instanceId: "counter-1", canvasId: "counter", input: { seed: 7 } }, + ]); + expect(result).toMatchObject({ + instanceId: "counter-1", + canvasId: "counter", + url: "https://example.test/counter-1", + availability: "ready", + }); + } finally { + await session.disconnect(); + } }); it("dispatches canvas.action.invoke to the per-action handler", async () => { @@ -79,25 +83,29 @@ describe("Canvas E2E", async () => { extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-2" }); - - const result = await session.rpc.canvas.invokeAction({ - canvasId: "counter", - instanceId: "counter-2", - actionName: "increment", - input: { amount: 3 }, - }); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-2" }); - expect(actions).toEqual([ - { - actionName: "increment", + const result = await session.rpc.canvas.invokeAction({ + canvasId: "counter", instanceId: "counter-2", + actionName: "increment", input: { amount: 3 }, - }, - ]); - expect(result).toEqual({ - result: { ok: true, actionName: "increment", input: { amount: 3 } }, - }); + }); + + expect(actions).toEqual([ + { + actionName: "increment", + instanceId: "counter-2", + input: { amount: 3 }, + }, + ]); + expect(result).toEqual({ + result: { ok: true, actionName: "increment", input: { amount: 3 } }, + }); + } finally { + await session.disconnect(); + } }); it("dispatches canvas.close to the provider onClose handler", async () => { @@ -109,13 +117,17 @@ describe("Canvas E2E", async () => { extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-3" }); - await session.rpc.canvas.close({ canvasId: "counter", instanceId: "counter-3" }); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-3" }); + await session.rpc.canvas.close({ canvasId: "counter", instanceId: "counter-3" }); - // onClose is fire-and-forget on the runtime side; allow a microtask flush. - await new Promise((r) => setTimeout(r, 50)); + // onClose is fire-and-forget on the runtime side; allow a microtask flush. + await new Promise((r) => setTimeout(r, 50)); - expect(closes).toEqual([{ instanceId: "counter-3", canvasId: "counter" }]); + expect(closes).toEqual([{ instanceId: "counter-3", canvasId: "counter" }]); + } finally { + await session.disconnect(); + } }); it("rejects invokeAction for an action the canvas did not declare", async () => { @@ -132,16 +144,20 @@ describe("Canvas E2E", async () => { extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-4" }); - - await expect( - session.rpc.canvas.invokeAction({ - canvasId: "counter", - instanceId: "counter-4", - actionName: "ghost", - input: {}, - }) - ).rejects.toThrow(/Unknown action "ghost"/); + try { + await session.rpc.canvas.open({ canvasId: "counter", instanceId: "counter-4" }); + + await expect( + session.rpc.canvas.invokeAction({ + canvasId: "counter", + instanceId: "counter-4", + actionName: "ghost", + input: {}, + }) + ).rejects.toThrow(/Unknown action "ghost"/); + } finally { + await session.disconnect(); + } }); it("seeds openCanvases on resume from the runtime resume response", async () => { @@ -155,23 +171,31 @@ describe("Canvas E2E", async () => { extensionInfo: { source: "github-app", name: "counter-provider" }, }); - await sessionA.rpc.canvas.open({ - canvasId: "counter", - instanceId: "counter-resume", - input: { initial: true }, - }); - - const resumed = await client.resumeSession(sessionA.sessionId, { - onPermissionRequest: approveAll, - canvases: [makeCounter({})], - requestCanvasRenderer: true, - extensionInfo: { source: "github-app", name: "counter-provider" }, - }); - - const seeded = resumed.openCanvases; - expect(seeded.length).toBeGreaterThan(0); - const match = seeded.find((c) => c.instanceId === "counter-resume"); - expect(match).toBeDefined(); - expect(match?.canvasId).toBe("counter"); + try { + await sessionA.rpc.canvas.open({ + canvasId: "counter", + instanceId: "counter-resume", + input: { initial: true }, + }); + + const resumed = await client.resumeSession(sessionA.sessionId, { + onPermissionRequest: approveAll, + canvases: [makeCounter({})], + requestCanvasRenderer: true, + extensionInfo: { source: "github-app", name: "counter-provider" }, + }); + + try { + const seeded = resumed.openCanvases; + expect(seeded.length).toBeGreaterThan(0); + const match = seeded.find((c) => c.instanceId === "counter-resume"); + expect(match).toBeDefined(); + expect(match?.canvasId).toBe("counter"); + } finally { + await resumed.disconnect(); + } + } finally { + await sessionA.disconnect(); + } }); }); diff --git a/python/e2e/test_canvas_e2e.py b/python/e2e/test_canvas_e2e.py index db4b224d1..939ea7eb1 100644 --- a/python/e2e/test_canvas_e2e.py +++ b/python/e2e/test_canvas_e2e.py @@ -84,23 +84,26 @@ async def test_dispatches_canvas_open_to_the_provider_handler(self, ctx: E2ETest handler = _CounterHandler() session = await _create_counter_session(ctx, handler) - result = await session.rpc.canvas.open( - CanvasOpenRequest( - canvas_id="counter", - instance_id="counter-1", - input={"seed": 7}, + try: + result = await session.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-1", + input={"seed": 7}, + ) ) - ) - assert len(handler.opens) == 1 - opened = handler.opens[0] - assert opened.canvas_id == "counter" - assert opened.instance_id == "counter-1" - assert opened.input == {"seed": 7} - assert result.canvas_id == "counter" - assert result.instance_id == "counter-1" - assert result.url == "https://example.test/counter-1" - assert result.availability == CanvasInstanceAvailability.READY + assert len(handler.opens) == 1 + opened = handler.opens[0] + assert opened.canvas_id == "counter" + assert opened.instance_id == "counter-1" + assert opened.input == {"seed": 7} + assert result.canvas_id == "counter" + assert result.instance_id == "counter-1" + assert result.url == "https://example.test/counter-1" + assert result.availability == CanvasInstanceAvailability.READY + finally: + await session.disconnect() async def test_dispatches_canvas_action_invoke_to_the_per_action_handler( self, ctx: E2ETestContext @@ -111,29 +114,32 @@ async def test_dispatches_canvas_action_invoke_to_the_per_action_handler( handler, actions=[CanvasAction(name="increment", description="Increment the counter")], ) - await session.rpc.canvas.open( - CanvasOpenRequest(canvas_id="counter", instance_id="counter-2") - ) + try: + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-2") + ) - result = await session.rpc.canvas.invoke_action( - CanvasInvokeActionRequest( - action_name="increment", - instance_id="counter-2", - input={"amount": 3}, + result = await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-2", + input={"amount": 3}, + ) ) - ) - assert len(handler.actions) == 1 - action = handler.actions[0] - assert action.canvas_id == "counter" - assert action.instance_id == "counter-2" - assert action.action_name == "increment" - assert action.input == {"amount": 3} - assert result.result == { - "ok": True, - "actionName": "increment", - "input": {"amount": 3}, - } + assert len(handler.actions) == 1 + action = handler.actions[0] + assert action.canvas_id == "counter" + assert action.instance_id == "counter-2" + assert action.action_name == "increment" + assert action.input == {"amount": 3} + assert result.result == { + "ok": True, + "actionName": "increment", + "input": {"amount": 3}, + } + finally: + await session.disconnect() async def test_dispatches_canvas_close_to_the_provider_on_close_handler( self, ctx: E2ETestContext @@ -141,16 +147,19 @@ async def test_dispatches_canvas_close_to_the_provider_on_close_handler( handler = _CounterHandler() session = await _create_counter_session(ctx, handler) - await session.rpc.canvas.open( - CanvasOpenRequest(canvas_id="counter", instance_id="counter-3") - ) - await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) - await asyncio.sleep(0.05) + try: + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-3") + ) + await session.rpc.canvas.close(CanvasCloseRequest(instance_id="counter-3")) + await asyncio.sleep(0.05) - assert len(handler.closes) == 1 - closed = handler.closes[0] - assert closed.canvas_id == "counter" - assert closed.instance_id == "counter-3" + assert len(handler.closes) == 1 + closed = handler.closes[0] + assert closed.canvas_id == "counter" + assert closed.instance_id == "counter-3" + finally: + await session.disconnect() async def test_returns_canvas_action_no_handler_when_declared_action_has_no_handler( self, ctx: E2ETestContext @@ -160,46 +169,57 @@ async def test_returns_canvas_action_no_handler_when_declared_action_has_no_hand _NoActionHandler(), actions=[CanvasAction(name="increment", description="Increment the counter")], ) - await session.rpc.canvas.open( - CanvasOpenRequest(canvas_id="counter", instance_id="counter-4") - ) + try: + await session.rpc.canvas.open( + CanvasOpenRequest(canvas_id="counter", instance_id="counter-4") + ) - with pytest.raises(JsonRpcError) as excinfo: - await session.rpc.canvas.invoke_action( - CanvasInvokeActionRequest( - action_name="increment", - instance_id="counter-4", - input={}, + with pytest.raises(JsonRpcError) as excinfo: + await session.rpc.canvas.invoke_action( + CanvasInvokeActionRequest( + action_name="increment", + instance_id="counter-4", + input={}, + ) ) - ) - assert excinfo.value.data == { - "code": "canvas_action_no_handler", - "message": "No handler implemented for this canvas action", - } + assert excinfo.value.data == { + "code": "canvas_action_no_handler", + "message": "No handler implemented for this canvas action", + } + finally: + await session.disconnect() async def test_seeds_open_canvases_on_resume_from_the_runtime_resume_response( self, ctx: E2ETestContext ): session_a = await _create_counter_session(ctx, _CounterHandler()) - await session_a.rpc.canvas.open( - CanvasOpenRequest( - canvas_id="counter", - instance_id="counter-resume", - input={"initial": True}, + try: + await session_a.rpc.canvas.open( + CanvasOpenRequest( + canvas_id="counter", + instance_id="counter-resume", + input={"initial": True}, + ) ) - ) - resumed = await ctx.client.resume_session( - session_a.session_id, - canvases=[_counter_declaration()], - request_canvas_renderer=True, - extension_info=_EXTENSION_INFO, - canvas_handler=_CounterHandler(), - ) + resumed = await ctx.client.resume_session( + session_a.session_id, + canvases=[_counter_declaration()], + request_canvas_renderer=True, + extension_info=_EXTENSION_INFO, + canvas_handler=_CounterHandler(), + ) - matching = [ - canvas for canvas in resumed.open_canvases if canvas.instance_id == "counter-resume" - ] - assert len(matching) == 1 - assert matching[0].canvas_id == "counter" + try: + matching = [ + canvas + for canvas in resumed.open_canvases + if canvas.instance_id == "counter-resume" + ] + assert len(matching) == 1 + assert matching[0].canvas_id == "counter" + finally: + await resumed.disconnect() + finally: + await session_a.disconnect()