fix: harden canvas review and runtime bootstrap (#3628)
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user