fix: harden canvas review and runtime bootstrap (#3628)

This commit is contained in:
Carlos Florêncio
2026-09-13 16:59:18 +01:00
committed by GitHub
parent 34a21812ca
commit 96b2814bfb
6 changed files with 542 additions and 44 deletions
@@ -75,6 +75,33 @@ const hostRuntimeBootstrap = `(() => {
const hostRuntimeBootstrapTag = `<script src="./_kandev/host-runtime.js"></script>`
type runtimeBootstrapNamespace uint8
const (
runtimeBootstrapHTMLNamespace runtimeBootstrapNamespace = iota
runtimeBootstrapSVGNamespace
runtimeBootstrapMathMLNamespace
runtimeBootstrapTemplateTag = "template"
)
var runtimeBootstrapForeignBreakoutTags = map[string]struct{}{
"b": {}, "big": {}, "blockquote": {}, "body": {}, "br": {}, "center": {}, "code": {}, "dd": {}, "div": {}, "dl": {}, "dt": {}, "em": {}, "embed": {},
"h1": {}, "h2": {}, "h3": {}, "h4": {}, "h5": {}, "h6": {}, "head": {}, "hr": {}, "i": {}, "img": {}, "li": {}, "listing": {}, "menu": {}, "meta": {},
"nobr": {}, "ol": {}, "p": {}, "pre": {}, "ruby": {}, "s": {}, "small": {}, "span": {}, "strong": {}, "strike": {}, "sub": {}, "sup": {}, "table": {}, "tt": {},
"u": {}, "ul": {}, "var": {},
}
type runtimeBootstrapOpenElement struct {
name string
namespace runtimeBootstrapNamespace
childNamespace runtimeBootstrapNamespace
}
type runtimeBootstrapParserState struct {
elements []runtimeBootstrapOpenElement
templateDepth int
}
func injectRuntimeBootstrap(entry []byte) ([]byte, error) {
if int64(len(entry)) > MaxFileBytes {
return nil, ErrRuntimeBootstrapUnavailable
@@ -94,12 +121,12 @@ func injectRuntimeBootstrap(entry []byte) ([]byte, error) {
}
func runtimeBootstrapInsertion(entry []byte) (int, error) {
tokenizer := html.NewTokenizer(bytes.NewReader(entry))
tokenizer.SetMaxBuf(len(entry) + 1)
position := 0
insertion := -1
fallback := -1
state := runtimeBootstrapParserState{}
for {
tokenType := tokenizer.Next()
raw := tokenizer.Raw()
@@ -112,7 +139,7 @@ func runtimeBootstrapInsertion(entry []byte) (int, error) {
if len(raw) == 0 {
return -1, ErrRuntimeBootstrapUnavailable
}
insertion, fallback = updateRuntimeBootstrapPosition(tokenizer, tokenType, raw, position, insertion, fallback)
insertion, fallback = updateRuntimeBootstrapPosition(tokenizer, tokenType, raw, position, insertion, fallback, &state)
position += len(raw)
}
if position != len(entry) {
@@ -121,44 +148,245 @@ func runtimeBootstrapInsertion(entry []byte) (int, error) {
if insertion < 0 {
insertion = fallback
}
if insertion < 0 || insertion > len(entry) {
return -1, ErrRuntimeBootstrapUnavailable
if insertion < 0 {
if state.templateDepth > 0 {
return -1, ErrRuntimeBootstrapUnavailable
}
// HTML supplies implied head and body elements when an entry omits the
// corresponding wrapper tags. Appending here keeps the original doctype
// and encoding declarations intact while placing the script in the
// implied body, outside any inert template content.
insertion = len(entry)
}
return insertion, nil
}
func updateRuntimeBootstrapPosition(tokenizer *html.Tokenizer, tokenType html.TokenType, raw []byte, position, insertion, fallback int) (int, int) {
name, hasTagName := runtimeBootstrapTagName(tokenizer, tokenType)
if !hasTagName {
func updateRuntimeBootstrapPosition(tokenizer *html.Tokenizer, tokenType html.TokenType, raw []byte, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) {
token, hasTag := runtimeBootstrapToken(tokenizer, tokenType)
if !hasTag {
return insertion, fallback
}
if tokenType == html.StartTagToken {
switch strings.ToLower(name) {
case "script":
if insertion < 0 {
insertion = position
}
case "head":
if fallback < 0 {
fallback = position + len(raw)
}
case "body":
if fallback < 0 {
fallback = position
}
}
return insertion, fallback
tagName := strings.ToLower(token.Data)
if tokenType != html.EndTagToken && state.shouldBreakOutOfForeignContent(tagName, token.Attr) {
state.popForeignContent()
}
if fallback < 0 && (strings.EqualFold(name, "head") || strings.EqualFold(name, "body") || strings.EqualFold(name, "html")) {
namespace := state.namespaceForTag(tagName)
if tokenType == html.EndTagToken {
return updateRuntimeBootstrapEndTag(tagName, namespace, position, insertion, fallback, state)
}
return updateRuntimeBootstrapStartTagPosition(tokenizer, token, tokenType, raw, tagName, namespace, position, insertion, fallback, state)
}
func updateRuntimeBootstrapEndTag(tagName string, namespace runtimeBootstrapNamespace, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) {
state.closeElement(tagName, namespace)
if namespace == runtimeBootstrapHTMLNamespace && state.templateDepth == 0 && fallback < 0 && isRuntimeBootstrapFallbackTag(tagName) {
fallback = position
}
return insertion, fallback
}
func runtimeBootstrapTagName(tokenizer *html.Tokenizer, tokenType html.TokenType) (string, bool) {
if tokenType != html.StartTagToken && tokenType != html.EndTagToken {
return "", false
func updateRuntimeBootstrapStartTagPosition(tokenizer *html.Tokenizer, token html.Token, tokenType html.TokenType, raw []byte, tagName string, namespace runtimeBootstrapNamespace, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) {
elementNamespace, childNamespace := state.elementNamespaces(tagName, token.Attr)
if elementNamespace != runtimeBootstrapHTMLNamespace {
// The tokenizer otherwise treats some foreign elements, such as SVG
// title, as HTML raw-text elements.
tokenizer.NextIsNotRawText()
}
name, _ := tokenizer.TagName()
return string(name), true
if tagName == "script" && tokenType == html.StartTagToken && state.templateDepth == 0 && insertion < 0 {
insertion = position
}
if elementNamespace == runtimeBootstrapHTMLNamespace && tagName == runtimeBootstrapTemplateTag {
state.templateDepth++
}
if tokenType == html.StartTagToken && namespace == runtimeBootstrapHTMLNamespace && state.templateDepth == 0 {
insertion, fallback = updateRuntimeBootstrapStartTag(tagName, raw, position, insertion, fallback)
}
state.openElement(tagName, elementNamespace, childNamespace, tokenType)
return insertion, fallback
}
func (state *runtimeBootstrapParserState) namespaceForTag(tagName string) runtimeBootstrapNamespace {
if len(state.elements) == 0 {
return runtimeBootstrapHTMLNamespace
}
top := state.elements[len(state.elements)-1]
if top.namespace == runtimeBootstrapMathMLNamespace && isRuntimeBootstrapMathMLTextIntegrationPoint(top.name) && tagName != "mglyph" && tagName != "malignmark" {
return runtimeBootstrapHTMLNamespace
}
return top.childNamespace
}
func (state *runtimeBootstrapParserState) shouldBreakOutOfForeignContent(tagName string, attrs []html.Attribute) bool {
if _, ok := runtimeBootstrapForeignBreakoutTags[tagName]; ok {
return true
}
if tagName != "font" {
return false
}
for _, attr := range attrs {
switch strings.ToLower(attr.Key) {
case "color", "face", "size":
return true
}
}
return false
}
func (state *runtimeBootstrapParserState) popForeignContent() {
for len(state.elements) > 0 {
top := state.elements[len(state.elements)-1]
if top.namespace == runtimeBootstrapHTMLNamespace || top.childNamespace == runtimeBootstrapHTMLNamespace {
return
}
state.elements = state.elements[:len(state.elements)-1]
}
}
func (state *runtimeBootstrapParserState) elementNamespaces(tagName string, attrs []html.Attribute) (runtimeBootstrapNamespace, runtimeBootstrapNamespace) {
parentNamespace := state.namespaceForTag(tagName)
switch parentNamespace {
case runtimeBootstrapHTMLNamespace:
switch tagName {
case "svg":
return runtimeBootstrapSVGNamespace, runtimeBootstrapSVGNamespace
case "math":
return runtimeBootstrapMathMLNamespace, runtimeBootstrapMathMLNamespace
default:
return runtimeBootstrapHTMLNamespace, runtimeBootstrapHTMLNamespace
}
case runtimeBootstrapSVGNamespace:
if isRuntimeBootstrapSVGHTMLIntegrationPoint(tagName) {
return runtimeBootstrapSVGNamespace, runtimeBootstrapHTMLNamespace
}
return runtimeBootstrapSVGNamespace, runtimeBootstrapSVGNamespace
case runtimeBootstrapMathMLNamespace:
if tagName == "annotation-xml" && hasRuntimeBootstrapHTMLAnnotationEncoding(attrs) {
return runtimeBootstrapMathMLNamespace, runtimeBootstrapHTMLNamespace
}
if isRuntimeBootstrapMathMLTextIntegrationPoint(tagName) {
return runtimeBootstrapMathMLNamespace, runtimeBootstrapHTMLNamespace
}
return runtimeBootstrapMathMLNamespace, runtimeBootstrapMathMLNamespace
default:
return runtimeBootstrapHTMLNamespace, runtimeBootstrapHTMLNamespace
}
}
func (state *runtimeBootstrapParserState) openElement(name string, namespace, childNamespace runtimeBootstrapNamespace, tokenType html.TokenType) {
if namespace == runtimeBootstrapHTMLNamespace && isRuntimeBootstrapHTMLVoidElement(name) {
return
}
if namespace != runtimeBootstrapHTMLNamespace && tokenType == html.SelfClosingTagToken {
return
}
state.elements = append(state.elements, runtimeBootstrapOpenElement{
name: name,
namespace: namespace,
childNamespace: childNamespace,
})
}
func (state *runtimeBootstrapParserState) closeElement(tagName string, namespace runtimeBootstrapNamespace) {
for index := len(state.elements) - 1; index >= 0; index-- {
element := state.elements[index]
if element.name == tagName && runtimeBootstrapEndTagMatches(element, namespace) {
for _, popped := range state.elements[index:] {
if popped.namespace == runtimeBootstrapHTMLNamespace && popped.name == runtimeBootstrapTemplateTag && state.templateDepth > 0 {
state.templateDepth--
}
}
state.elements = state.elements[:index]
return
}
if runtimeBootstrapEndTagStopsAt(element, tagName, namespace) {
return
}
}
}
func runtimeBootstrapEndTagStopsAt(element runtimeBootstrapOpenElement, tagName string, namespace runtimeBootstrapNamespace) bool {
if element.namespace == runtimeBootstrapHTMLNamespace && element.name == runtimeBootstrapTemplateTag && tagName != runtimeBootstrapTemplateTag {
return true
}
if namespace == runtimeBootstrapHTMLNamespace && element.namespace != runtimeBootstrapHTMLNamespace && element.childNamespace == runtimeBootstrapHTMLNamespace && element.name != tagName {
return true
}
return namespace != runtimeBootstrapHTMLNamespace && element.namespace == runtimeBootstrapHTMLNamespace
}
func runtimeBootstrapEndTagMatches(element runtimeBootstrapOpenElement, namespace runtimeBootstrapNamespace) bool {
if element.namespace == namespace {
return true
}
return namespace == runtimeBootstrapHTMLNamespace && element.namespace != runtimeBootstrapHTMLNamespace && element.childNamespace == runtimeBootstrapHTMLNamespace
}
func isRuntimeBootstrapSVGHTMLIntegrationPoint(tagName string) bool {
switch tagName {
case "desc", "foreignobject", "title":
return true
default:
return false
}
}
func isRuntimeBootstrapMathMLTextIntegrationPoint(tagName string) bool {
switch tagName {
case "mi", "mo", "mn", "ms", "mtext":
return true
default:
return false
}
}
func hasRuntimeBootstrapHTMLAnnotationEncoding(attrs []html.Attribute) bool {
for _, attr := range attrs {
if strings.EqualFold(attr.Key, "encoding") && (strings.EqualFold(attr.Val, "text/html") || strings.EqualFold(attr.Val, "application/xhtml+xml")) {
return true
}
}
return false
}
func isRuntimeBootstrapHTMLVoidElement(tagName string) bool {
switch tagName {
case "area", "base", "basefont", "bgsound", "br", "col", "embed", "frame", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr":
return true
default:
return false
}
}
func updateRuntimeBootstrapStartTag(tagName string, raw []byte, position, insertion, fallback int) (int, int) {
switch tagName {
case "script":
if insertion < 0 {
insertion = position
}
case "head":
if fallback < 0 {
fallback = position + len(raw)
}
case "body":
if fallback < 0 {
fallback = position
}
}
return insertion, fallback
}
func isRuntimeBootstrapFallbackTag(tagName string) bool {
switch tagName {
case "head", "body", "html":
return true
default:
return false
}
}
func runtimeBootstrapToken(tokenizer *html.Tokenizer, tokenType html.TokenType) (html.Token, bool) {
if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken && tokenType != html.EndTagToken {
return html.Token{}, false
}
return tokenizer.Token(), true
}
@@ -201,6 +201,117 @@ func TestRuntimeStartupBootstrapPreservesArtifact(t *testing.T) {
}
}
func TestRuntimeStartupBootstrapSupportsOmittedHTMLWrappers(t *testing.T) {
entry := `<!doctype html><meta charset="utf-8"><title>Report</title><p>Hello</p>`
archive := canvasArchive(t, map[string]string{
"manifest.yaml": staticManifestYAML,
"ui/index.html": entry,
})
pkg, err := ValidatePackage(bytes.NewReader(archive))
if err != nil {
t.Fatalf("ValidatePackage: %v", err)
}
artifacts, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatalf("NewArtifactStore: %v", err)
}
artifact, err := artifacts.Put(pkg)
if err != nil {
t.Fatalf("Put: %v", err)
}
manager := NewTokenManager(nil)
token, err := manager.Issue(CapabilityBinding{
UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main",
Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html",
}, 0)
if err != nil {
t.Fatalf("Issue: %v", err)
}
runtime := NewRuntime(manager, artifacts, nil, nil)
response := httptest.NewRecorder()
runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, "")
if response.Code != http.StatusOK {
t.Fatalf("entry status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
bootstrap := `<script src="./_kandev/host-runtime.js"></script>`
if !strings.HasPrefix(body, `<!doctype html><meta charset="utf-8">`) {
t.Fatalf("doctype or encoding prefix changed: %q", body)
}
if strings.Index(body, bootstrap) <= strings.Index(body, "<p>Hello</p>") {
t.Fatalf("host bootstrap was not appended to the implied body: %q", body)
}
if got := response.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(body)) {
t.Fatalf("Content-Length = %q, want %d", got, len(body))
}
stored, err := os.ReadFile(filepath.Join(artifacts.Path(artifact), "ui", "index.html"))
if err != nil {
t.Fatalf("ReadFile stored entry: %v", err)
}
if string(stored) != entry {
t.Fatalf("stored entry changed: %q", stored)
}
}
func TestInjectRuntimeBootstrapSkipsTemplateContent(t *testing.T) {
entry := `<!doctype html><template><script>window.__template = true;</script></template><script src="./app.js"></script>`
result, err := injectRuntimeBootstrap([]byte(entry))
if err != nil {
t.Fatalf("injectRuntimeBootstrap: %v", err)
}
body := string(result)
bootstrap := `<script src="./_kandev/host-runtime.js"></script>`
bootstrapIndex := strings.Index(body, bootstrap)
templateEnd := strings.Index(body, "</template>")
authoredScript := strings.Index(body, `<script src="./app.js">`)
if bootstrapIndex <= templateEnd || bootstrapIndex >= authoredScript {
t.Fatalf("host bootstrap was inserted outside executable document order: %q", body)
}
}
func TestInjectRuntimeBootstrapRejectsUnclosedHTMLTemplate(t *testing.T) {
for _, entry := range []string{
`<template><p>Hello`,
`<div><template><script>window.__template = true;</script></div>`,
} {
if _, err := injectRuntimeBootstrap([]byte(entry)); !errors.Is(err, ErrRuntimeBootstrapUnavailable) {
t.Fatalf("injectRuntimeBootstrap(%q) error = %v, want %v", entry, err, ErrRuntimeBootstrapUnavailable)
}
}
}
func TestInjectRuntimeBootstrapDoesNotTreatForeignTemplateAsInert(t *testing.T) {
entry := `<svg><template><script>window.__svg = true;</script></template></svg><script src="./app.js"></script>`
result, err := injectRuntimeBootstrap([]byte(entry))
if err != nil {
t.Fatalf("injectRuntimeBootstrap: %v", err)
}
body := string(result)
bootstrap := `<script src="./_kandev/host-runtime.js"></script>`
bootstrapIndex := strings.Index(body, bootstrap)
foreignScript := strings.Index(body, `<script>window.__svg = true;</script>`)
if bootstrapIndex < 0 || foreignScript < 0 || bootstrapIndex >= foreignScript {
t.Fatalf("host bootstrap was inserted after foreign executable script: %q", body)
}
}
func TestInjectRuntimeBootstrapTracksHTMLTemplateAfterForeignBreakout(t *testing.T) {
entry := `<svg><p><template><script>window.__template = true;</script></template></p></svg><script src="./app.js"></script>`
result, err := injectRuntimeBootstrap([]byte(entry))
if err != nil {
t.Fatalf("injectRuntimeBootstrap: %v", err)
}
body := string(result)
bootstrap := `<script src="./_kandev/host-runtime.js"></script>`
bootstrapIndex := strings.Index(body, bootstrap)
templateScript := strings.Index(body, `<script>window.__template = true;</script>`)
authoredScript := strings.Index(body, `<script src="./app.js">`)
if bootstrapIndex <= templateScript || bootstrapIndex >= authoredScript {
t.Fatalf("host bootstrap was inserted inside HTML template content: %q", body)
}
}
func TestRuntimeBootstrapFailureDoesNotKeepArtifactContentLength(t *testing.T) {
archive := canvasArchive(t, map[string]string{
"manifest.yaml": staticManifestYAML,
@@ -517,6 +517,15 @@ describe("CanvasReleaseDialog review surface", () => {
expect(screen.getByRole("button", { name: COPY.rollbackRelease })).toBeTruthy();
});
it("uses the wide responsive desktop review surface", async () => {
mockListCanvasReleases.mockResolvedValue({ releases: [release()] });
render(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await screen.findByTestId("canvas-release-review-release-pending");
expect(screen.getByTestId("canvas-releases-dialog").className).toContain("sm:max-w-[48rem]");
});
it("keeps the phone review full-height with a fixed safe-area footer", async () => {
responsive.isMobile = true;
mockListCanvasReleases.mockResolvedValue({ releases: [release()] });
@@ -393,7 +393,7 @@ export function CanvasReleaseDialog({
selectedRelease.id !== canvas?.active_release_id;
const surfaceClassName = isMobile
? "!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 overflow-hidden rounded-none p-0 [padding-top:max(1rem,env(safe-area-inset-top))]"
: "flex h-[min(90dvh,48rem)] max-h-[calc(100dvh-2rem)] w-full max-w-[48rem] flex-col gap-0 overflow-hidden p-0";
: "flex h-[min(90dvh,48rem)] max-h-[calc(100dvh-2rem)] w-full sm:max-w-[48rem] flex-col gap-0 overflow-hidden p-0";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
+62 -14
View File
@@ -29,6 +29,17 @@ export type CanvasRecord = {
};
};
export type CanvasReleaseRecord = {
id: string;
validation_status?: string;
permissions?: {
reads?: string[];
writes?: string[];
events?: string[];
shared_state?: boolean;
};
};
export type SeededCanvas = {
taskId: string;
taskSessionId: string;
@@ -126,7 +137,22 @@ export async function getCanvas(
return (await response.json()) as CanvasRecord;
}
async function waitForSessionWorkspace(
export async function listCanvasReleases(
apiClient: ApiClient,
canvasId: string,
): Promise<CanvasReleaseRecord[]> {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/canvases/${encodeURIComponent(canvasId)}/releases`,
);
if (!response.ok) {
throw new Error(`Canvas release lookup failed (${response.status}).`);
}
const body = (await response.json()) as { releases?: CanvasReleaseRecord[] };
return body.releases ?? [];
}
export async function waitForSessionWorkspace(
apiClient: ApiClient,
taskId: string,
sessionId: string,
@@ -150,8 +176,30 @@ async function waitForSessionWorkspace(
return path.resolve(workspacePath);
}
function canvasManifest(canvas: CanvasRecord): string {
export type CanvasSourceOptions = {
noPermissions?: boolean;
minimalPermissions?: boolean;
};
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
if (options?.noPermissions) return [];
if (options?.minimalPermissions) return [" api_read:", " - tasks", " - workflows"];
return [
" api_read:",
" - tasks",
" - workflows",
" api_write:",
" - tasks",
" - messages",
" events:",
" - task.updated",
" state: true",
];
}
function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): string {
const compactId = canvas.id.replaceAll("-", "").slice(0, 12);
const capabilities = canvasCapabilities(options);
return [
`id: "canvas-${compactId}"`,
"api_version: 2",
@@ -168,15 +216,7 @@ function canvasManifest(canvas: CanvasRecord): string {
" - task-canvas",
" - workspace-canvas",
"capabilities:",
" api_read:",
" - tasks",
" - workflows",
" api_write:",
" - tasks",
" - messages",
" events:",
" - task.updated",
" state: true",
...capabilities,
"",
].join("\n");
}
@@ -371,10 +411,14 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
})();`;
}
export function writeCanvasSource(workspacePath: string, canvas: CanvasRecord): void {
export function writeCanvasSource(
workspacePath: string,
canvas: CanvasRecord,
options?: CanvasSourceOptions,
): void {
const sourceDirectory = path.join(workspacePath, ".kandev", "canvases", canvas.id);
fs.mkdirSync(sourceDirectory, { recursive: true });
fs.writeFileSync(path.join(sourceDirectory, "manifest.yaml"), canvasManifest(canvas));
fs.writeFileSync(path.join(sourceDirectory, "manifest.yaml"), canvasManifest(canvas, options));
fs.writeFileSync(
path.join(sourceDirectory, "index.html"),
[
@@ -420,6 +464,7 @@ export async function seedTaskCanvas(
apiClient: ApiClient,
seedData: SeedData,
useMobileSubmit = false,
sourceOptions?: CanvasSourceOptions,
): Promise<SeededCanvas> {
const title = "E2E Plugin Canvas";
const script = [
@@ -458,6 +503,7 @@ export async function seedTaskCanvas(
session,
canvas,
useMobileSubmit,
sourceOptions,
});
return {
@@ -501,6 +547,7 @@ type PublishTaskCanvasOptions = {
session: SessionPage;
canvas: CanvasRecord;
useMobileSubmit?: boolean;
sourceOptions?: CanvasSourceOptions;
};
export async function publishTaskCanvas({
@@ -510,9 +557,10 @@ export async function publishTaskCanvas({
session,
canvas,
useMobileSubmit = false,
sourceOptions,
}: PublishTaskCanvasOptions): Promise<CanvasRecord> {
const workspacePath = await waitForSessionWorkspace(apiClient, taskId, taskSessionId);
writeCanvasSource(workspacePath, canvas);
writeCanvasSource(workspacePath, canvas, sourceOptions);
const sourcePath = `.kandev/canvases/${canvas.id}`;
const publishScript = `e2e:mcp:kandev:publish_canvas_kandev(${JSON.stringify({
canvas_id: canvas.id,
@@ -1,12 +1,17 @@
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { waitForSessionDone } from "../../helpers/session";
import { resizeColumnViaSplitview } from "../../helpers/dockview-resize";
import { SessionPage } from "../../pages/session-page";
import {
canvasHref,
enableCanvasFeature,
expectCanvasFrameFillsHost,
listCanvasReleases,
removeCanvas,
seedTaskCanvas,
waitForSessionWorkspace,
writeCanvasSource,
} from "./canvas-fixture";
test.describe("Plugin-backed canvases in the desktop task workbench", () => {
@@ -344,4 +349,101 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
await releaseFeature();
}
});
test("keeps the desktop release review wide without scrolling two permissions", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
await testPage.setViewportSize({ width: 1280, height: 720 });
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, {
noPermissions: true,
});
canvasId = seeded.canvas.id;
const workspacePath = await waitForSessionWorkspace(
apiClient,
seeded.taskId,
seeded.taskSessionId,
);
writeCanvasSource(workspacePath, seeded.canvas, { minimalPermissions: true });
const publishScript = `e2e:mcp:kandev:publish_canvas_kandev(${JSON.stringify({
canvas_id: seeded.canvas.id,
source_path: `.kandev/canvases/${seeded.canvas.id}`,
})})`;
const reviewSession = await apiClient.launchSession({
task_id: seeded.taskId,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: seedData.worktreeExecutorProfileId,
workflow_step_id: seedData.startStepId,
prompt: `e2e:delay(2500)\n${publishScript}`,
});
const reviewWorkspacePath = await waitForSessionWorkspace(
apiClient,
seeded.taskId,
reviewSession.session_id,
);
writeCanvasSource(reviewWorkspacePath, seeded.canvas, { minimalPermissions: true });
await waitForSessionDone(
apiClient,
seeded.taskId,
reviewSession.session_id,
"The permission-increasing canvas publication did not finish.",
45_000,
);
let pendingReleaseId: string | undefined;
await expect
.poll(
async () => {
const releases = await listCanvasReleases(apiClient, canvasId!);
pendingReleaseId = releases.find(
(release) => release.validation_status === "pending_permission",
)?.id;
return pendingReleaseId ?? null;
},
{
timeout: 30_000,
message: "The permission-increasing canvas release did not pend.",
},
)
.not.toBeNull();
expect(pendingReleaseId).toBeTruthy();
await testPage.goto(canvasHref(canvasId));
const releasesButton = testPage.getByRole("button", {
name: "Releases and permissions",
exact: true,
});
await expect(releasesButton).toBeVisible({
timeout: 20_000,
});
await releasesButton.click();
const dialog = testPage.getByTestId("canvas-releases-dialog");
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox?.width).toBeGreaterThanOrEqual(720);
expect(dialogBox?.width).toBeLessThanOrEqual(780);
const permissions = dialog.getByTestId("canvas-permission-summary");
await expect(permissions).toBeVisible();
await expect(permissions.locator("li")).toHaveCount(2);
const scrollMetrics = await dialog
.getByTestId("canvas-release-review-scroll")
.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(scrollMetrics.scrollHeight).toBeLessThanOrEqual(scrollMetrics.clientHeight);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
});