package web import ( "net/http" "net/http/httptest" "strings" "testing" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) func chatPage(t *testing.T, names ...string) string { t.Helper() models := make([]ChatModel, 0, len(names)) for _, n := range names { models = append(models, ChatModel{Name: n}) } return chatPageWith(t, models) } func chatPageWith(t *testing.T, models []ChatModel) string { t.Helper() h := Chat(store.User{Email: "a@example.com"}, models, nil, "", "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, "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") } // Only the server's rendering of a finished reply is placed as HTML; see // TestTheTruncationNoticeIsNotPutInsideTheReply. if strings.Count(page, "innerHTML") != 1 { t.Error("the chat page assigns innerHTML beyond the rendered reply") } } 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, "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, "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, "alpha", "beta") for _, m := range []string{"alpha", "beta"} { if !strings.Contains(page, `value="`+m+`"`) { t.Errorf("the picker does not offer %s", m) } } empty := chatPageWith(t, nil) if strings.Contains(empty, "
big-model (simulated)<`) { t.Error("the simulated model is offered without saying it is simulated") } if strings.Contains(page, `>qwen (simulated)<`) { t.Error("a real model was labelled simulated") } if !strings.Contains(page, `value="big-model" data-simulated="1"`) || !strings.Contains(page, `value="qwen" data-simulated="0"`) { t.Error("the script cannot tell the two apart") } if !strings.Contains(page, "placeholder text, not inference") { t.Error("the page never explains what a simulated model returns") } } func TestTheFirstOfferedModelAnswers(t *testing.T) { // A browser selects the first option, so whatever leads the list is what // someone gets when they type and press Send without looking at the picker. page := chatPageWith(t, []ChatModel{ {Name: "qwen"}, {Name: "big-model", Simulated: true}, }) real, sim := strings.Index(page, `value="qwen"`), strings.Index(page, `value="big-model"`) if real < 0 || sim < 0 { t.Fatal("both models should be offered") } if real > sim { t.Error("a simulated model is preselected; the default reply would be placeholder text") } } func TestATruncatedReplySaysSo(t *testing.T) { // A reply that hit the token cap looks exactly like one that finished: the // stream ends and the text stops. Reported as the model not completing the // request, which is a fair reading when nothing says otherwise. page := chatPage(t, "m") if !strings.Contains(page, "finish_reason") { t.Error("the page never reads why the model stopped") } if !strings.Contains(page, `finish === "length"`) { t.Error("the page does not distinguish running out of room from finishing") } if !strings.Contains(page, "Cut off at the length limit") { t.Error("nothing tells the reader the answer was cut short") } } func TestTheTruncationNoticeIsNotPutInsideTheReply(t *testing.T) { // out holds model output placed with textContent. The notice is ours, so // it goes beside that element — putting it inside would mean building // markup in the same node the model writes into. page := chatPage(t, "m") if !strings.Contains(page, "out.after(note)") { t.Error("the notice is not appended as a sibling of the reply") } // The one exception is the finished reply as /chat/render returns it — // markdown the server has already made safe — and nothing else may be // written into the reply as HTML. if n := strings.Count(page, "innerHTML ="); n != 1 || !strings.Contains(page, "out.innerHTML = await resp.text();") || !strings.Contains(page, `fetch("/chat/render"`) { t.Errorf("the reply is written as HTML other than from /chat/render (%d assignments)", n) } } func TestChatModelRunsNamesTheHardware(t *testing.T) { for _, c := range []struct { hw []string want string }{ {nil, ""}, {[]string{"cpu"}, "CPU"}, {[]string{"6000-24g"}, "GPU"}, {[]string{"cpu", "6000-24g"}, "GPU + CPU"}, } { if got := (ChatModel{Hardware: c.hw}).Runs(); got != c.want { t.Errorf("%v: %q, want %q", c.hw, got, c.want) } } }