Replies are plain text, so a model asked about an integral answers with \int_0^1 x^2 dx and the reader gets the source. KaTeX is vendored into the binary rather than loaded from a CDN. This gateway makes no outbound requests of its own and is expected to run where it could not make one; a chat page that silently loses its maths on an air-gapped cluster would be worse than one that never had it. 600 KB of JS, CSS and WOFF2 fonts, which the stripped binary absorbs without changing its rounded size. The ordering is the safety argument, and there is a test for it rather than a comment alone. The reply is put in place with textContent exactly as before, and only then walked for maths: renderMathInElement replaces the spans it recognises, so nothing the model wrote is ever parsed as HTML and the markup that appears is KaTeX's. trust stays false — KaTeX's default, and what keeps \href and \includegraphics out of a reply — and throwOnError is off so one malformed expression does not take the rest with it. Rendered once the reply is complete. Typesetting each chunk would try to set half an expression on every token and flicker through the whole answer. The static handler now serves a small embedded asset tree rather than one file, so it moved to its own embed: a file server over the tree that also carries the templates would serve those too. Directory listings are refused — nothing links to one, so the only requests are from somebody looking around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
3.3 KiB
Go
104 lines
3.3 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func chatPage(t *testing.T, models []string) string {
|
|
t.Helper()
|
|
h := Chat(store.User{Email: "a@example.com", ProjectID: "proj"}, models, "v1")
|
|
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
|
|
w := httptest.NewRecorder()
|
|
h(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d", w.Code)
|
|
}
|
|
return w.Body.String()
|
|
}
|
|
|
|
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
|
|
// The ordering in the page is the safety argument, so it is worth asserting
|
|
// rather than remembering: the reply is put in place with textContent and
|
|
// only then walked for maths. KaTeX replaces the spans it recognises, so
|
|
// nothing the model wrote is ever parsed as HTML.
|
|
page := chatPage(t, []string{"m"})
|
|
|
|
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
|
|
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
|
|
t.Fatal("the page does not load KaTeX")
|
|
}
|
|
// Vendored, not fetched: this gateway makes no outbound requests and is
|
|
// expected to run where it could not.
|
|
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
|
|
if strings.Contains(page, cdn) {
|
|
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(page, "trust: false") {
|
|
t.Error("trust is not pinned off; \\href in model output would become a link")
|
|
}
|
|
if !strings.Contains(page, "throwOnError: false") {
|
|
t.Error("a single malformed expression would take the whole reply with it")
|
|
}
|
|
|
|
// The reply still reaches the page as text first.
|
|
if !strings.Contains(page, "out.textContent = reply") {
|
|
t.Error("the reply is no longer written with textContent")
|
|
}
|
|
if strings.Contains(page, "innerHTML") {
|
|
t.Error("the chat page assigns innerHTML somewhere")
|
|
}
|
|
}
|
|
|
|
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
|
|
// Typesettingevery chunk would try to render half an expression on every token
|
|
// and flicker through the whole reply.
|
|
page := chatPage(t, []string{"m"})
|
|
body := page[strings.Index(page, "form.onsubmit"):]
|
|
|
|
renderAt := strings.Index(body, "renderMath(out)")
|
|
streamAt := strings.Index(body, "out.textContent = reply")
|
|
if renderAt < 0 || streamAt < 0 {
|
|
t.Fatal("the streaming loop or the render call is missing")
|
|
}
|
|
if renderAt < streamAt {
|
|
t.Error("maths is rendered before the text that carries it")
|
|
}
|
|
}
|
|
|
|
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
|
|
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
|
|
// inline expression and everything after it is wrong.
|
|
page := chatPage(t, []string{"m"})
|
|
dd := strings.Index(page, `left: "$$"`)
|
|
d := strings.Index(page, `left: "$"`)
|
|
if dd < 0 || d < 0 {
|
|
t.Fatal("the delimiters are missing")
|
|
}
|
|
if dd > d {
|
|
t.Error(`$ is tried before $$`)
|
|
}
|
|
}
|
|
|
|
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
|
|
page := chatPage(t, []string{"alpha", "beta"})
|
|
for _, m := range []string{"alpha", "beta"} {
|
|
if !strings.Contains(page, `value="`+m+`"`) {
|
|
t.Errorf("the picker does not offer %s", m)
|
|
}
|
|
}
|
|
empty := chatPage(t, nil)
|
|
if strings.Contains(empty, "<form id=\"composer\"") {
|
|
t.Error("a composer was rendered with no model to send to")
|
|
}
|
|
if !strings.Contains(empty, "No models are available") {
|
|
t.Error("the page does not explain why it is empty")
|
|
}
|
|
}
|