tinyrelayfile
tinyrelay/tinyclient/custom_views_test.go/

tinyclient/custom_views_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/policy"
13 "github.com/FelineStateMachine/tinyrelay/internal/views"
14 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
15)
16
17// viewsBackend reports two custom views and lists them for the owner.
18type viewsBackend struct {
19 fakeBackend
20 views []views.View
21 rows []map[string]any
22}
23
24func (b *viewsBackend) CustomViews() []views.View { return b.views }
25
26func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
27 if method == "listcustomviews" {
28 if actor != b.policy.Owner {
29 return nil, context.Canceled
30 }
31 return b.rows, nil
32 }
33 if method == "browserepo" {
34 var request map[string]any
35 if len(params) == 1 {
36 _ = json.Unmarshal(params[0], &request)
37 }
38 if request["view"] == "file" {
39 return map[string]any{"content": "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n"}, nil
40 }
41 return map[string]any{"identifier": "notes", "entries": []any{}}, nil
42 }
43 return b.fakeBackend.Query(ctx, method, params, actor)
44}
45
46const mermaidSource = "graph TD;\n A-->B & <C>;"
47
48func mermaidFigure(view string) string {
49 return `<figure data-view="` + view + `"><view-artifact><img src="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A--&gt;B &amp; &lt;C&gt;;</code></pre></details></view-artifact></figure>`
50}
51
52func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {
53 renderer := views.NewRenderer([]views.View{{Name: "diagrams", Languages: []string{"mermaid"}}})
54 source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n"
55 got := string(renderMarkdownWith(source, renderer.Block))
56 wantAll(t, "markdown", got, mermaidFigure("diagrams"), `<pre><code data-lang="go">func main() {}</code></pre>`)
57 if strings.Contains(got, "class=") {
58 t.Fatal("markdown carries a class attribute")
59 }
60 // Without a renderer, every fenced block stays code.
61 plain := string(renderMarkdown(source))
62 wantAll(t, "plain markdown", plain, `<pre><code data-lang="mermaid">graph TD;`)
63 wantNone(t, "plain markdown", plain, "<figure", "<object")
64 // Chat keeps the figure and links the bare URL outside it.
65 chat := string(renderChatMarkdownWith(source, renderer.Block))
66 wantAll(t, "chat", chat, mermaidFigure("diagrams"), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>`)
67 // README rendering maps relative links and keeps the figure.
68 query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}}
69 repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block))
70 wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repo?owner=alice&amp;path=docs%2Fguide.md&amp;ref=main&amp;repo=notes&amp;view=file"`)
71 // The wiki renderer takes the same renderer through its options.
72 article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block}))
73 wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>")
74 if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n")); strings.Contains(plainWiki, "<figure") || !strings.Contains(plainWiki, `<code data-lang="mermaid">`) {
75 t.Fatalf("wiki without options: %s", plainWiki)
76 }
77}
78
79func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
80 owner := strings.Repeat("a", 64)
81 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}
82 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})
83 if err != nil {
84 t.Fatal(err)
85 }
86 source := "```mermaid\n" + mermaidSource + "\n```"
87 for name, got := range map[string]string{
88 "markdown": string(app.markdown(source)),
89 "chat": string(app.chatMarkdown(source)),
90 "wiki": string(app.wikiHTML(source)),
91 } {
92 wantAll(t, name, got, mermaidFigure("diagrams"))
93 }
94 // The repository home renders the README through the same renderer,
95 // and the tenant prefix reaches the object address.
96 recorder := httptest.NewRecorder()
97 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner="+owner+"&repo=notes&view=home", nil))
98 body := recorder.Body.String()
99 if recorder.Code != http.StatusOK {
100 t.Fatalf("repo home: %d %s", recorder.Code, body)
101 }
102 hash := views.Hash("mermaid", "graph TD; a-->b;")
103 figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a--&gt;b;</code></pre></details></view-artifact></figure>`
104 wantAll(t, "repo home", body, figure, `href="/repo?owner=`)
105 if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) {
106 t.Fatalf("tenant prefix missed the object address: %s", prefixed)
107 }
108 // A backend without the summary keeps code blocks as code.
109 plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})
110 if err != nil {
111 t.Fatal(err)
112 }
113 if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure") {
114 t.Fatalf("figure without a summary: %s", got)
115 }
116 backend.views = nil
117 if got := string(app.markdown(source)); strings.Contains(got, "<figure") {
118 t.Fatalf("figure without views: %s", got)
119 }
120}
121
122func TestTemplateClonesKeepBackendSpecificMarkdownHelpers(t *testing.T) {
123 owner := strings.Repeat("a", 64)
124 withView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}
125 withView.policy.Description = "```mermaid\ngraph TD; A-->B\n```"
126 withoutView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}
127 withoutView.policy.Description = withView.policy.Description
128 withApp, err := New(withView, Options{})
129 if err != nil {
130 t.Fatal(err)
131 }
132 withoutApp, err := New(withoutView, Options{})
133 if err != nil {
134 t.Fatal(err)
135 }
136 withResponse := httptest.NewRecorder()
137 withoutResponse := httptest.NewRecorder()
138 withApp.ServeHTTP(withResponse, httptest.NewRequest(http.MethodGet, "/", nil))
139 withoutApp.ServeHTTP(withoutResponse, httptest.NewRequest(http.MethodGet, "/", nil))
140 if !strings.Contains(withResponse.Body.String(), `<figure data-view="diagrams">`) {
141 t.Fatal("backend with a custom view did not render its figure")
142 }
143 if strings.Contains(withoutResponse.Body.String(), `<figure data-view="diagrams">`) {
144 t.Fatal("backend without a custom view inherited another App's markdown helper")
145 }
146}
147
148func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {
149 owner := strings.Repeat("a", 64)
150 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{
151 {"name": "diagrams", "kinds": []any{1.0, 30023.0}, "languages": []any{"mermaid", "dot"}, "trigger": "write", "audience": "public", "enabled": true, "state": "active", "lastRun": 1700000000.0, "lastStatus": "ok", "transform": "https://render.example/tiny?token=secret-path"},
152 {"name": "charts", "kinds": []any{30618.0}, "languages": []any{"vega"}, "trigger": "hourly", "audience": "members", "enabled": false, "state": "paused", "lastRun": 0.0, "lastStatus": "paused after 20 failures: HTTP 500"},
153 }}
154 app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})
155 if err != nil {
156 t.Fatal(err)
157 }
158 get := func(actor string) string {
159 t.Helper()
160 request := httptest.NewRequest(http.MethodGet, "/manage/views", nil)
161 request.Header.Set("X-Actor", actor)
162 recorder := httptest.NewRecorder()
163 app.ServeHTTP(recorder, request)
164 if recorder.Code != http.StatusOK {
165 t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String())
166 }
167 body := recorder.Body.String()
168 if strings.Contains(body, "class=") {
169 t.Fatal("views page carries a class attribute")
170 }
171 if strings.Contains(body, "ยท") {
172 t.Fatal("views page contains a middle dot")
173 }
174 return body
175 }
176 body := get(owner)
177 wantAll(t, "/manage/views", body,
178 `<rpc-form method="listviews">`, `<rpc-form method="publishview">`,
179 `<table id="custom-views">`, `<tr id="view-diagrams">`, `<code>diagrams</code>`, `<td>1, 30023</td>`, `<td>mermaid, dot</td>`, `<td>write</td>`, `<td>public</td>`,
180 `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`,
181 `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`,
182 `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`,
183 `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`,
184 `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`,
185 `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`,
186 `<view-form method="addcustomview">`, `<input name="view-name" pattern="[a-z0-9-]{1,32}"`, `<input name="view-kinds"`, `<input name="view-languages"`, `<input name="view-transform" type="url"`,
187 `<select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select>`, `<select name="view-audience">`, `<input name="view-max-bytes" type="number" min="1" max="4194304"`, `<input name="view-secret" minlength="16" maxlength="128"`, `<button>Add view</button>`)
188 wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`)
189 // A member keeps the built-in controls and sees no custom view section.
190 member := get(strings.Repeat("b", 64))
191 wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`)
192 wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form")
193 // The owner without views sees the empty note and the form.
194 backend.rows = nil
195 empty := get(owner)
196 wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`)
197 wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`)
198}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs