gui: a routing group's members say whether they see images (#885)

* gui: a group's members say what each can do, images included

A routing group's editor said of a member its name, its provider and the
reasoning it is sent at, and nothing of whether it sees images — a group
mixing members that see with ones that don't gave no way to tell one from
another, and which member a picture would reach was found out by sending one.

Each member now carries the same chips the Gateway page's model list sums a
model up in (modelInfo): its reasoning levels, whether it sees images and the
window it holds. The group's own models API carries Images and ImageSet for
them (internal/gui/groups.go), and the chips' look is shared with the Gateway
list's.

A member whose list says nothing of images is said to be unknown, not "text
only": magpie counts it text-only for a describer (gateway.blindTo), which is
not its list saying it takes none. For the same reason the group's own line —
"Accepts images (every model in it does)" — said something a group no longer
is: since #756 one member that sees is enough, so it reads "one of its models
does" now, in English, Chinese, Japanese and German.

group-member-info.test.cjs (new) checks three members — one that sees, one its
list says takes none, one nothing was read of — for their chips, their
tooltip's words in both languages, and that the row still switches off and
removes. Routing view regressions pass as on the baseline.

Validation:
- node --test --test-concurrency=1 internal/gui/tests/group-member-info.test.cjs
- group-arrange, routing-manual, group-member-off, group-context, group-fold,
  group-patterns, group-bulk-remove, group-member-fast, group-found-off,
  agent-model-groups, member-model-setting, group-from-model-add, click-scroll:
  32 pass, 2 fail — both the 600x420 group-from-model-add cases, which fail
  identically on the unmodified 9e77092.

# Conflicts:
#	internal/gui/assets/routing.js

* gui: test that a group's models carry what they take

The chips the Routing editor draws a member with read the served catalog
entry: whether agents are told it takes images, whether anything said so
either way, its reasoning levels and the window it holds. The first is the
same expression the gateway decides by when it describes an image for a
model that can't see one (blindTo reads Entry.Images), so the editor cannot
say a member sees images that the gateway would describe them for. A model
whose list says nothing is carried as unknown, not as text only.

Covers: one that sees, one its list says takes none, one nothing was read
of, and one the user answered for themselves (settings.ModelImages).

* gui: carry a member's unknown images as a flag, not an absent key

Review of the chips: `imageSet` was omitempty on both structs, so a model
magpie knows nothing about went out without the key, not with `false` —
`m.imageSet === false` never held, and such a model still read "Text only",
which is what the chips set out to avoid. The browser test passed only
because its fixture wrote `imageSet: false`, which groupsState() never
produces.

`imagesUnknown` is the flag now, sent only when true: a model magpie has an
answer for carries no such key at all, and the page reads `=== true`. The
name is apart from the Gateway page's `imageSet` on purpose — there it means
the user answered for the model themselves (provider.ImageOverride), and
modelInfo is shared by both pages.

The fixture now writes what groupsState() emits: `images` and
`imagesUnknown` left out where they are false. A Go test holds the wire
shape (`TestGroupModelImagesOnTheWire`), and with the fixture as it now is
the browser test fails 2/2 on the old check ("a member nothing was read of
says unknown, not text only: Reasoning levels: none known").

Validation: go vet ./... and go build ./... pass; go test ./internal/gui/
fails only TestSetPortMovesTheGateway and TestPurposeAssetMatchesUsage,
which fail identically on the unmodified 9e77092; the routing and group
browser regressions are 34 pass, 2 fail — both the 600x420
group-from-model-add cases, which fail identically on the baseline.

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
This commit is contained in:
wizzy-yangandwizzy-yang authored and GitHub committed 2026-10-05 17:42:25 +08:00
1 parent 358cfbae79
commit afc6623ce5
7 files changed
+356 -12

No files matched your search

+7 -5
View File
@@ -1753,11 +1753,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.badge.mapi-tag { background: var(--accent-soft); color: var(--accent); letter-spacing: .02em; text-transform: none; }
.mchip .badge { margin: 0 -3px 0 1px; padding: 0 4px; font-size: 9px; line-height: 14px; }
.row.model .name .badge { margin-left: 6px; padding: 0 4px; font: 600 9px/14px var(--font); }
.row.model .minfo { display: inline-flex; align-items: center; gap: 4px; flex: none; margin-left: 8px; cursor: help; }
.row.model .minfo .badge { display: inline-flex; align-items: center; padding: 0 5px; font: 600 9.5px/16px var(--font); letter-spacing: .02em; text-transform: none; background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); vertical-align: 0; }
.row.model .minfo .mi-ctx { font-variant-numeric: tabular-nums; }
.row.model .minfo .mi-img { padding: 0 4px; }
.row.model .minfo .mi-img svg { display: block; }
/* the chips a model is summed up in, where a list of models is read: the
Gateway page's list, and a routing group's members in its editor */
.row.model .minfo, .rt-gedit .fbrow .minfo { display: inline-flex; align-items: center; gap: 4px; flex: none; margin-left: 8px; cursor: help; }
.row.model .minfo .badge, .rt-gedit .fbrow .minfo .badge { display: inline-flex; align-items: center; padding: 0 5px; font: 600 9.5px/16px var(--font); letter-spacing: .02em; text-transform: none; background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); vertical-align: 0; }
.row.model .minfo .mi-ctx, .rt-gedit .fbrow .minfo .mi-ctx { font-variant-numeric: tabular-nums; }
.row.model .minfo .mi-img, .rt-gedit .fbrow .minfo .mi-img { padding: 0 4px; }
.row.model .minfo .mi-img svg, .rt-gedit .fbrow .minfo .mi-img svg { display: block; }
.provider .pswitch { margin-left: 8px; }
.editor .off-note { grid-column: 1 / -1; }
.uses { display: flex; align-items: center; gap: 4px; margin-left: auto; flex: none; }
+6 -1
View File
@@ -5389,12 +5389,17 @@ function modelInfo(m) {
box.append(el("span", "badge mi-effort", effortSpan(levels)));
lines.push(t("Reasoning: {levels}", { levels: levels.map((l) => t(l)).join(", ") }));
} else lines.push(t("Reasoning levels: none known"));
// nothing read of its images either way: magpie counts it text-only for a
// describer (gateway.blindTo), which is not the same as its list saying so,
// so it is not called text-only here either. Only a model that carries the
// flag is unknown; one magpie has an answer for carries no such key at all.
const imgUnknown = m.imagesUnknown === true;
if (m.images) {
const c = el("span", "badge mi-img");
c.append(svg(IMAGE_GLYPH, 11, 1.4));
box.append(c);
}
lines.push(t(m.images ? (m.group ? "Accepts images (every model in it does)" : "Accepts images") : "Text only"));
lines.push(t(imgUnknown ? "Images: not known" : m.images ? (m.group ? "Accepts images (one of its models does)" : "Accepts images") : "Text only"));
if (m.context) {
box.append(el("span", "badge mi-ctx", ctxShort(m.context)));
lines.push(t(m.group ? "Context: {n} tokens (the largest of its models')" : "Context: {n} tokens", { n: m.context.toLocaleString() }));
+6 -3
View File
@@ -725,7 +725,8 @@ const I18N = {
"Reasoning: {levels}": "推理强度:{levels}",
"Reasoning levels: none known": "推理强度:未知",
"Decision model: routing groups ask it, agents never see it": "决策模型:路由组会问它,agent 看不到它",
"Accepts images (every model in it does)": "支持图片输入(组内每个模型都支持)",
"Accepts images (one of its models does)": "支持图片输入(组内有一个模型支持)",
"Images: not known": "图片:未知",
"Text only": "仅文本",
"Context: {n} tokens": "上下文:{n} tokens",
"Context: {n} tokens (the largest of its models')": "上下文:{n} tokens(组内模型中最大的)",
@@ -3937,7 +3938,8 @@ const I18N = {
"Reasoning: {levels}": "推論:{levels}",
"Reasoning levels: none known": "推論レベル:不明",
"Decision model: routing groups ask it, agents never see it": "決定モデル:ルーティンググループが問い合わせ、エージェントには見えません",
"Accepts images (every model in it does)": "画像入力に対応(含まれるすべてのモデルが対応)",
"Accepts images (one of its models does)": "画像入力に対応(含まれるいずれかのモデルが対応)",
"Images: not known": "画像:不明",
"Text only": "テキストのみ",
"Context: {n} tokens": "コンテキスト:{n} トークン",
"Context: {n} tokens (the largest of its models')": "コンテキスト:{n} トークン(含まれるモデルの最大値)",
@@ -7139,7 +7141,8 @@ const I18N = {
"Reasoning: {levels}": "Reasoning: {levels}",
"Reasoning levels: none known": "Reasoning-Stufen: keine bekannt",
"Decision model: routing groups ask it, agents never see it": "Entscheidungsmodell: Routing-Gruppen fragen es, Agents sehen es nie",
"Accepts images (every model in it does)": "Akzeptiert Bilder (jedes enthaltene Modell)",
"Accepts images (one of its models does)": "Akzeptiert Bilder (eines seiner Modelle)",
"Images: not known": "Bilder: unbekannt",
"Text only": "Nur Text",
"Context: {n} tokens": "Kontext: {n} Token",
"Context: {n} tokens (the largest of its models')": "Kontext: {n} Token (der größte seiner Modelle)",
+9
View File
@@ -2933,6 +2933,15 @@
sw.onclick = () => { d.off = off ? d.off.filter((x) => x !== id) : [...d.off, id]; draw(); };
if (off) row.classList.add("muted");
row.append(sw, matched ? el("span", "i", String(i + 1)) : memberHandle(id, i, row), memberIcon(id), n, el("span", "grow"));
// what the model is, in the same chips the Gateway page's list says it
// in (modelInfo): its reasoning levels, whether it sees images and the
// window it holds. A group mixing members that see images with ones
// that don't had nothing here to tell one from another, and which
// member a picture would reach was only found out by sending one.
// A member whose list says nothing of images is marked unknown rather
// than text-only: magpie counts it text-only for a describer
// (gateway.blindTo), which is not the same as its list saying so.
if (m) row.append(modelInfo({ ...m, images: infoOf(id)?.images ?? m.images, imagesUnknown: infoOf(id)?.imagesUnknown ?? m.imagesUnknown }));
// the reasoning the model is sent at in this group: the group's
// (blank), or one of its own whatever the agent asks. A group in
// it reasons as it says.
+174
View File
@@ -0,0 +1,174 @@
package gui
import (
"encoding/json"
"os"
"testing"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// A routing group's editor reads each member off the served catalog entry:
// whether agents are told it takes images, whether anything said so either
// way, the levels it reasons at and the window it holds. The first is the
// same expression the gateway decides by when it describes an image for a
// model that can't see one (blindTo reads Entry.Images), so the editor cannot
// say a member sees images that the gateway would describe them for.
func TestGroupModelsCarryWhatTheyTake(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
yes, no := true, false
if err := provider.Save(provider.Provider{ID: "p", Name: "P", Key: "k", Chat: "http://127.0.0.1:1/v1",
Models: []string{"eye", "text", "mystery", "mine"}}); err != nil {
t.Fatal(err)
}
// what each model's own list says: one sees, one takes none, one says
// nothing at all
if err := catalog.SaveLive("p", "http://127.0.0.1:1/v1", []catalog.Model{
{ID: "eye", Context: 1048576, Efforts: []string{"low", "medium", "high"}, Images: true, ImageInput: &yes},
{ID: "text", Context: 200000, ImageInput: &no},
{ID: "mystery", Context: 128000},
{ID: "mine", Context: 64000, ImageInput: &no},
}); err != nil {
t.Fatal(err)
}
// the user's own answer for one its list said took none
st := settings.Load()
st.ModelImages = map[string]bool{"p/mine": true}
if err := settings.Save(st); err != nil {
t.Fatal(err)
}
if err := provider.SaveGroup(provider.Group{ID: "g", Name: "G", Members: []string{"p/eye", "p/text", "p/mystery", "p/mine"}}); err != nil {
t.Fatal(err)
}
byID := map[string]modelRef{}
for _, m := range groupsState().Models {
byID[m.ID] = m
}
for _, tc := range []struct {
id string
images bool
unknown bool
context int
efforts int
}{
{"p/eye", true, false, 1048576, 3}, // its list says it sees
{"p/text", false, false, 200000, 0}, // its list says it takes none
{"p/mystery", false, true, 128000, 0}, // nothing was read of it either way
{"p/mine", true, false, 64000, 0}, // the user's answer, over its list
} {
m, ok := byID[tc.id]
if !ok {
t.Fatalf("%s is not in the group's models", tc.id)
}
if m.Images != tc.images || m.ImagesUnknown != tc.unknown {
t.Errorf("%s: images %v unknown %v, want %v %v", tc.id, m.Images, m.ImagesUnknown, tc.images, tc.unknown)
}
if m.Context != tc.context {
t.Errorf("%s: context %d, want %d", tc.id, m.Context, tc.context)
}
if len(m.Efforts) != tc.efforts {
t.Errorf("%s: efforts %v, want %d of them", tc.id, m.Efforts, tc.efforts)
}
}
// and the group's own members say the same of them
var g groupJSON
for _, x := range groupsState().Groups {
if x.ID == "g" {
g = x
}
}
if len(g.Info) != 4 {
t.Fatalf("the group lists %d members", len(g.Info))
}
for i, want := range []struct {
id string
images bool
unknown bool
}{{"p/eye", true, false}, {"p/text", false, false}, {"p/mystery", false, true}, {"p/mine", true, false}} {
if g.Info[i].ID != want.id || g.Info[i].Images != want.images || g.Info[i].ImagesUnknown != want.unknown {
t.Errorf("member %d is %s images %v unknown %v, want %s %v %v", i, g.Info[i].ID, g.Info[i].Images, g.Info[i].ImagesUnknown, want.id, want.images, want.unknown)
}
}
}
// The page reads a member's images off the wire: a model magpie has an answer
// for carries no imagesUnknown key at all (and `images` is omitempty, so a
// model that takes none carries no images key either), while one nothing was
// read of carries imagesUnknown: true. What the browser test's fixture writes
// is this shape, not one of its own.
func TestGroupModelImagesOnTheWire(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
yes, no := true, false
if err := provider.Save(provider.Provider{ID: "p", Name: "P", Key: "k", Chat: "http://127.0.0.1:1/v1",
Models: []string{"eye", "text", "mystery"}}); err != nil {
t.Fatal(err)
}
if err := catalog.SaveLive("p", "http://127.0.0.1:1/v1", []catalog.Model{
{ID: "eye", Images: true, ImageInput: &yes},
{ID: "text", ImageInput: &no},
{ID: "mystery"},
}); err != nil {
t.Fatal(err)
}
if err := provider.SaveGroup(provider.Group{ID: "g", Name: "G", Members: []string{"p/eye", "p/text", "p/mystery"}}); err != nil {
t.Fatal(err)
}
b, err := json.Marshal(groupsState())
if err != nil {
t.Fatal(err)
}
var doc struct {
Models []map[string]any `json:"models"`
Groups []struct {
Info []map[string]any `json:"memberInfo"`
} `json:"groups"`
}
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
keys := func(m map[string]any) (images, unknown any) { return m["images"], m["imagesUnknown"] }
byID := map[string]map[string]any{}
for _, m := range doc.Models {
byID[m["id"].(string)] = m
}
for _, tc := range []struct {
id string
images bool
unknown bool
}{
{"p/eye", true, false},
{"p/text", false, false},
{"p/mystery", false, true},
} {
m, ok := byID[tc.id]
if !ok {
t.Fatalf("%s is not in the models", tc.id)
}
img, unk := keys(m)
if img != nil && img != tc.images || (img == nil) == tc.images {
t.Errorf("%s: images on the wire is %v, want %v (a false is left out)", tc.id, img, tc.images)
}
if unk != nil && unk != tc.unknown || (unk == nil) == tc.unknown {
t.Errorf("%s: imagesUnknown on the wire is %v, want %v (only true is sent)", tc.id, unk, tc.unknown)
}
}
// the same for a group's own members
for _, m := range doc.Groups[0].Info {
want := byID[m["id"].(string)]
img, unk := keys(m)
wantImg, wantUnk := keys(want)
if (img == nil) != (wantImg == nil) || img != wantImg || (unk == nil) != (wantUnk == nil) || unk != wantUnk {
t.Errorf("member %v carries images %v unknown %v, the model carries %v %v", m["id"], img, unk, wantImg, wantUnk)
}
}
}
+26 -3
View File
@@ -66,6 +66,13 @@ type memberJSON struct {
// what a rule may send it: the tokens it takes, when known, and images
Context int `json:"context,omitempty"`
Images bool `json:"images,omitempty"`
// ImagesUnknown: nothing was read of its images either way, so it counts
// text-only for a describer (gateway.blindTo) without its list having
// said it takes none. Sent only when true: a model magpie has an answer
// for carries no such key, which is what the page reads as known (its
// own `images` decides then). Named apart from the Gateway page's
// `imageSet`, which means the user answered for the model themselves
ImagesUnknown bool `json:"imagesUnknown,omitempty"`
}
type modelRef struct {
@@ -81,6 +88,12 @@ type modelRef struct {
// CanFast: a group's member of it may be sent in its vendor's fast
// mode (provider.CanFast)
CanFast bool `json:"canFast,omitempty"`
// Images: agents are told it takes images (provider.Entry.Images); a
// group's editor says of each member what it says of it (#756).
// ImagesUnknown: nothing was read of it either way (imagesUnknown),
// sent only when true
Images bool `json:"images,omitempty"`
ImagesUnknown bool `json:"imagesUnknown,omitempty"`
}
type poolJSON struct {
@@ -97,6 +110,16 @@ type poolJSON struct {
Protocol provider.Protocol `json:"protocol,omitempty"`
}
// seesImages is whether agents are told the model takes images: its list
// says so, and nothing said otherwise (provider.Entry).
func seesImages(e provider.Entry) bool { return e.Images && (e.ImageInput == nil || *e.ImageInput) }
// imagesUnknown is whether nothing was read of the model's images either way:
// no list said so, and what magpie reads of it doesn't say it sees. Such a
// model counts text-only for a describer (gateway.blindTo), which is not its
// list saying it takes none, and the page says unknown rather than text-only.
func imagesUnknown(e provider.Entry) bool { return e.ImageInput == nil && !e.Images }
// onOf is who a provider's requests spread over: its accounts, or keys.
func onOf(p provider.Provider) (kind string, who []string) {
if p.Account != nil {
@@ -149,7 +172,7 @@ func groupsState() groupsJSON {
served := provider.Served()
for _, e := range served {
if e.Group == "" {
out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context, Efforts: e.Efforts, CanFast: provider.CanFast(e.Provider, e.Model)})
out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context, Efforts: e.Efforts, CanFast: provider.CanFast(e.Provider, e.Model), Images: seesImages(e), ImagesUnknown: imagesUnknown(e)})
}
}
for _, g := range provider.Groups() {
@@ -179,7 +202,7 @@ func groupsState() groupsJSON {
if e.ID == id {
_, ms, _ := provider.FindGroup(id)
m.Ready, m.Name, m.Icon, m.On = true, e.Name, e.Provider.Icon, len(ms)
m.Context, m.Images = e.Context, e.Images && (e.ImageInput == nil || *e.ImageInput)
m.Context, m.Images, m.ImagesUnknown = e.Context, seesImages(e), imagesUnknown(e)
gj.Ready = true
break
}
@@ -199,7 +222,7 @@ func groupsState() groupsJSON {
m.Fast = m.CanFast && g.IsFast(id)
for _, e := range served {
if e.Group == "" && e.Provider.ID == p.ID && e.Model == model {
m.Context, m.Images = e.Context, e.Images && (e.ImageInput == nil || *e.ImageInput)
m.Context, m.Images, m.ImagesUnknown = e.Context, seesImages(e), imagesUnknown(e)
break
}
}
@@ -0,0 +1,128 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group's member could not be read for what it is: the editor said
// its name, its provider and the reasoning it is sent at, and nothing of
// whether it sees images — which member a picture would reach was found out by
// sending one. Each member now carries the same chips the Gateway page's model
// list says a model in (modelInfo): its reasoning levels, whether it sees
// images and the window it holds. A member whose list says nothing of images
// is marked unknown, not "text only": magpie counts it text-only for a
// describer (gateway.blindTo), which is not its list saying it takes none. In
// English and Chinese, Chromium and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
// three members, written as groupsState() emits them: one that sees images,
// one whose list says it takes none, and one nothing was read of. `images` is
// omitempty, so a model that doesn't take them carries no images key at all;
// only a model nothing was read of carries imagesUnknown, and it is sent only
// when true — which is what the page reads as unknown.
const models = [
{ id: "p/eye", name: "Eye", providerName: "P", icon: "generic", context: 1048576, efforts: ["low", "medium", "high"], images: true },
{ id: "p/text", name: "Text", providerName: "P", icon: "generic", context: 200000 },
{ id: "p/mystery", name: "Mystery", providerName: "P", icon: "generic", context: 128000, imagesUnknown: true },
];
const group = {
id: "g", name: "Mixed", routing: "", ready: true,
members: models.map((m) => m.id),
memberInfo: models.map((m) => ({
id: m.id, ready: true, name: m.name, provider: "p", model: m.id, icon: "generic", context: m.context,
...(m.images ? { images: true } : {}), ...(m.imagesUnknown ? { imagesUnknown: true } : {}),
})),
};
const words = {
en: { images: "Accepts images", text: "Text only", unknown: "Images: not known", levels: "low–high" },
zh: { images: "支持图片输入", text: "仅文本", unknown: "图片:未知", levels: "低–高" },
};
function serve(lang) {
const groups = () => ({ models, pools: [], deciders: [], found: false, groups: [group] });
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/groups") return json(groups());
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
// each member row's chips: the badges drawn, and the lines of its tooltip
const rows = (page) => page.locator(".rt-gedit .fbrow").evaluateAll((rs) => rs.map((r) => {
const info = r.querySelector(".minfo");
return {
name: (r.querySelector(".n > span") || {}).textContent || "",
badges: info ? [...info.querySelectorAll(".badge")].map((b) => b.textContent.trim() || "img") : [],
title: info ? info.title : "",
img: !!(info && info.querySelector(".mi-img")),
};
}));
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a group's members say what each can do`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-member-info.png`) });
}
await browser.close();
});
const page = await (await browser.newContext({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
await page.goto("http://magpie.test/?view=routing");
await page.locator(".rt-group[data-id=g]").waitFor();
await page.locator(".rt-group[data-id=g]").click();
await page.locator(".rt-gedit .fbrow").first().waitFor();
const seen = await rows(page);
assert.equal(seen.length, 3, "every member has its row");
assert.deepEqual(seen.map((r) => r.name), ["Eye", "Text", "Mystery"], "the rows are the members, in order");
// what it sees: the badge on the one that sees, and no badge on the others
assert(seen[0].img, "the member that sees images carries the image chip");
assert(!seen[1].img && !seen[2].img, "a member that does not see carries none");
// and the same said in words, in the tooltip
assert(seen[0].title.includes(w.images), `the seeing member says so: ${seen[0].title}`);
assert(seen[1].title.includes(w.text), `the text-only member says so: ${seen[1].title}`);
assert(seen[2].title.includes(w.unknown), `a member nothing was read of says unknown, not text only: ${seen[2].title}`);
assert(!seen[2].title.includes(w.text), "and is not called text only");
// the rest of the chips: its levels and the window it holds
assert(seen[0].badges.includes(w.levels), `its reasoning levels: ${JSON.stringify(seen[0].badges)}`);
assert(seen[0].badges.includes("1.0M"), `the window it holds: ${JSON.stringify(seen[0].badges)}`);
assert.deepEqual(errors, [], "no page error");
// the row is still the row it was: switching it off and removing it
const off = page.locator(".rt-gedit .fbrow").first().locator(".rt-mon");
await off.click();
assert.equal(await off.getAttribute("aria-checked"), "false", "the member is switched off");
await off.click();
assert.equal(await off.getAttribute("aria-checked"), "true", "and on again");
await page.locator(".rt-gedit .fbrow").last().locator("button.text", { hasText: lang === "zh" ? "移除" : "Remove" }).click();
assert.equal((await rows(page)).length, 2, "the last member is removed");
});
}
}