Files
kandev/apps/backend/internal/task/share/gist_html_test.go
T
João Salavisa 07a9784d1c fix(share): route share links through gist.githack so big tasks render (#1008)
* fix(share): route share links through gist.githack so big tasks render

gistpreview.github.io fetches gists via the public GitHub API, which returns
`content: ""` for files past GitHub's per-response content budget (~1 MB).
For non-trivial sessions, share.html and snapshot.json land past that
budget and gistpreview's `document.write(content)` renders a blank page.
gist.githack.com proxies the raw file content directly, so multi-MB
share.html renders correctly — at the cost of a one-time Cloudflare
anti-phishing interstitial on first visit, accepted as the price of
fidelity for big tasks.

displayURL now normalises bare gist URLs and unpinned githack URLs to the
canonical `gist.githack.com/<owner>/<id>/raw/share.html` form; legacy
gistpreview URLs pass through unchanged (the owner is not recoverable
from a gistpreview URL, so users can revoke + re-share to get the
githack form going forward).

* fix(share): address PR #1008 review feedback

- displayURL now re-pins legacy gistpreview URLs stored without /share.html
  so they hit the styled view instead of silently falling back to the
  alphabetically-first README.md (flagged by Greptile).
- Spec: unify two stale gistpreview references (API endpoint description
  and the publish scenario) to the canonical githack form, with a note
  that legacy gistpreview rows pass through. Flagged by CodeRabbit and
  Claude — the spec is the public contract; one stale mention undermines
  the migration.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 11:40:36 +01:00

225 lines
7.1 KiB
Go

package share
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuildShareHTML_RendersChatLayout(t *testing.T) {
t.Parallel()
completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
snap := &Snapshot{
Version: SnapshotVersion,
ExportedAt: completed,
Task: TaskMeta{Title: "Investigate <flaky> test"},
Session: SessionMeta{
AgentType: "claude-acp",
Model: "claude-opus-4-7",
ExecutorType: "local_docker",
StartedAt: completed.Add(-time.Minute),
CompletedAt: &completed,
},
Messages: []Message{
{Role: roleUser, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "why is X flaky?"}}},
{Role: roleAssistant, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "Looking into it."}}},
{Role: roleAssistant, Ts: completed, Blocks: []Block{
{Kind: blockKindToolCall, ToolName: "shell", Text: "ran tests",
Args: json.RawMessage(`{"cmd":"go test ./..."}`)},
}},
{Role: roleAssistant, Ts: completed, Blocks: []Block{
{Kind: blockKindToolResult, Output: "FAIL pkg/foo TestX"},
{Kind: blockKindText, Text: "Found it. Use `t.Cleanup`."},
{Kind: blockKindDiff, Path: "src/x.go",
UnifiedDiff: "--- a/x.go\n+++ b/x.go\n@@ -1,1 +1,1 @@\n-old\n+new\n context\n"},
}},
},
Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}},
}
doc := BuildShareHTML(snap)
assertContains(t, doc, "<!doctype html>")
assertContains(t, doc, "<title>Investigate &lt;flaky&gt; test — kandev share</title>")
assertContains(t, doc, "<style>")
if strings.Contains(doc, "<flaky>") {
t.Fatal("unescaped angle brackets leaked into HTML")
}
assertContains(t, doc, ">claude-acp<")
assertContains(t, doc, ">4 messages<")
assertContains(t, doc, "abs-path")
}
func TestBuildShareHTML_GroupsConsecutiveAssistantMessages(t *testing.T) {
t.Parallel()
snap := &Snapshot{
Task: TaskMeta{Title: "T"},
Messages: []Message{
{Role: roleUser, Blocks: []Block{{Kind: blockKindText, Text: "hi"}}},
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindText, Text: "a1"}}},
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindToolCall, ToolName: "shell"}}},
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindText, Text: "a2"}}},
{Role: roleUser, Blocks: []Block{{Kind: blockKindText, Text: "ok"}}},
},
}
doc := BuildShareHTML(snap)
// 3 groups expected: user, assistant (merged 3 messages), user — so 3 sections.
if got := strings.Count(doc, `<section class="group `); got != 3 {
t.Fatalf("expected 3 groups, got %d in:\n%s", got, doc)
}
// User-right + assistant-left CSS classes both present.
assertContains(t, doc, "group-user")
assertContains(t, doc, "group-assistant")
}
func TestBuildShareHTML_ToolCallIsCollapsedByDefault(t *testing.T) {
t.Parallel()
snap := &Snapshot{
Task: TaskMeta{Title: "T"},
Messages: []Message{
{Role: roleAssistant, Blocks: []Block{
{Kind: blockKindToolCall, ToolName: "shell", Text: "go test",
Args: json.RawMessage(`{"cmd":"go test"}`)},
}},
},
}
doc := BuildShareHTML(snap)
// `<details>` without `open` is closed by default.
if strings.Contains(doc, "<details class=\"tool tool-call\" open") {
t.Fatal("tool call must not be open by default")
}
assertContains(t, doc, "<details class=\"tool tool-call\">")
assertContains(t, doc, ">shell<")
assertContains(t, doc, "go test")
}
func TestBuildShareHTML_DiffLineClasses(t *testing.T) {
t.Parallel()
snap := &Snapshot{
Task: TaskMeta{Title: "T"},
Messages: []Message{
{Role: roleAssistant, Blocks: []Block{
{Kind: blockKindDiff, Path: "src/x.go",
UnifiedDiff: "--- a\n+++ b\n@@ -1,1 +1,1 @@\n-old\n+new\n ctx\n"},
}},
},
}
doc := BuildShareHTML(snap)
for _, cls := range []string{"diff-add", "diff-del", "diff-hunk", "diff-file", "diff-ctx"} {
if !strings.Contains(doc, `class="`+cls+`"`) {
t.Fatalf("missing class %q in diff: %s", cls, doc)
}
}
}
func TestBuildShareHTML_InlineBackticksAndFencedCode(t *testing.T) {
t.Parallel()
snap := &Snapshot{
Task: TaskMeta{Title: "T"},
Messages: []Message{
{Role: roleAssistant, Blocks: []Block{
{Kind: blockKindText, Text: "Use `t.Cleanup` to dispose.\n\n```go\nfunc TestX(t *testing.T) { t.Cleanup(cleanup) }\n```\n\nDone."},
}},
},
}
doc := BuildShareHTML(snap)
assertContains(t, doc, `<code class="inline">t.Cleanup</code>`)
assertContains(t, doc, `<pre class="code lang-go"><code>`)
assertContains(t, doc, "func TestX(t *testing.T)")
assertContains(t, doc, "<p>Done.</p>")
}
func TestBuildShareHTML_EmptyMessagesShowsPlaceholder(t *testing.T) {
t.Parallel()
doc := BuildShareHTML(&Snapshot{Task: TaskMeta{Title: "Empty"}})
assertContains(t, doc, `class="empty"`)
assertContains(t, doc, "No messages.")
}
func TestBuildShareHTML_NilSafe(t *testing.T) {
t.Parallel()
if got := BuildShareHTML(nil); got == "" {
t.Fatal("nil snapshot should produce a placeholder document")
}
}
func TestGroupMessages_RunsFusedAcrossEmptyMessages(t *testing.T) {
t.Parallel()
groups := groupMessages([]Message{
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindText, Text: "a"}}},
{Role: roleAssistant, Blocks: []Block{}}, // dropped (empty)
{Role: roleAssistant, Blocks: []Block{{Kind: blockKindText, Text: "b"}}},
})
if len(groups) != 1 || len(groups[0].blocks) != 2 {
t.Fatalf("expected 1 group with 2 blocks, got %+v", groups)
}
}
func TestInlineFormat_UnmatchedBacktickIsRendered(t *testing.T) {
t.Parallel()
got := inlineFormat("an unmatched ` here")
if !strings.Contains(got, "`") {
t.Fatalf("expected unmatched backtick preserved, got %q", got)
}
}
func TestSanitiseLang_StripsUnsafe(t *testing.T) {
t.Parallel()
if got := sanitiseLang("GO-1.21"); got != "go-121" {
t.Fatalf("got %q", got)
}
if got := sanitiseLang("<script>"); got != "script" {
t.Fatalf("got %q", got)
}
}
func TestRenderedURLForGist(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want string
}{
{"authenticated_gist", "https://gist.github.com/jane/abc123", "https://gist.githack.com/jane/abc123/raw/share.html"},
// Anonymous gists lack the owner segment githack needs to address the
// file; we return "" so callers fall back to whatever was stored.
{"anonymous_gist", "https://gist.github.com/abc123", ""},
{"wrong_host", "https://example.com/jane/abc123", ""},
{"empty", "", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := renderedURLForGist(tc.input); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestOwnerAndIDFromGithackURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
wantOwner string
wantID string
}{
{"valid_url", "https://gist.githack.com/jane/abc123/raw/share.html", "jane", "abc123"},
{"wrong_host", "https://example.com/jane/abc123/raw/share.html", "", ""},
{"too_short", "https://gist.githack.com/jane", "", ""},
{"empty", "", "", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
owner, id := ownerAndIDFromGithackURL(tc.input)
if owner != tc.wantOwner || id != tc.wantID {
t.Fatalf("got (%q,%q), want (%q,%q)", owner, id, tc.wantOwner, tc.wantID)
}
})
}
}