| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "html" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "regexp" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // §5.4: the rename is the bar's editable title — a real form posting to |
| 16 | // the rename route, only for someone who may write. A reader sees the |
| 17 | // title read-only. |
| 18 | func TestTheBarRenamesTheSheet(t *testing.T) { |
| 19 | app, cookies := signIn(t) |
| 20 | s, _ := newSheet(t, app, cookies, "Budget") |
| 21 | body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| 22 | for _, want := range []string{ |
| 23 | `<form estilo-bar-title method="post" action="/sheets/` + strconv.FormatUint(s.ID, 10) + `/rename">`, |
| 24 | `<input estilo-bar-title-input name="title" value="Budget" aria-label="Sheet title"`, |
| 25 | `<button rst-btn="ghost" type="submit" estilo-bar-title-submit>Rename</button>`, |
| 26 | } { |
| 27 | if !strings.Contains(body, want) { |
| 28 | t.Errorf("the writer's bar lacks %s", want) |
| 29 | } |
| 30 | } |
| 31 | if strings.Contains(body, "data-doc-title") { |
| 32 | t.Error("the old in-bar rename form is still rendered") |
| 33 | } |
| 34 | // A reader: a viewer grant only, signed in through the real link. |
| 35 | if _, err := app.grant(s.ID, "reader@example.com", SheetViewer, signedInMember(t, app)); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | postSignin(t, app, "reader@example.com") |
| 39 | reader := followLink(t, app.Router(), app.testMailer(t).lastLink(t)).Result().Cookies() |
| 40 | body = page(t, app, reader, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| 41 | if strings.Contains(body, "estilo-bar-title") || !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Budget">Budget</span>`) { |
| 42 | t.Error("a reader is offered the rename, or does not see the title") |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // The rename works through the bar with scripting off: Enter posts the |
| 47 | // form, and the sheet comes back renamed. |
| 48 | func TestTheBarsRenameFormRenames(t *testing.T) { |
| 49 | app, cookies := signIn(t) |
| 50 | s, _ := newSheet(t, app, cookies, "Budget") |
| 51 | rec := do(t, app, as(postForm("/sheets/"+strconv.FormatUint(s.ID, 10)+"/rename", map[string][]string{"title": {"Commission Q2"}}), cookies)) |
| 52 | if rec.Code != http.StatusSeeOther { |
| 53 | t.Fatalf("rename = %d", rec.Code) |
| 54 | } |
| 55 | if body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String(); !strings.Contains(body, `value="Commission Q2"`) || titleOf(t, body) != "Commission Q2 · Sheets" { |
| 56 | t.Error("the bar does not show the new title") |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // §0.5 and §12.1 item 2: <meta charset> first, then the stylesheets in |
| 61 | // the suite's order — tokens, theme, brand, estilo base, menu, chrome, |
| 62 | // then Sheets' own. |
| 63 | func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) { |
| 64 | body := page(t, New(Config{}), nil, "/signin").Body.String() |
| 65 | head := body[strings.Index(body, "<head>"):strings.Index(body, "</head>")] |
| 66 | if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(head, "<head>")), `<meta charset="utf-8">`) { |
| 67 | t.Error("<meta charset> is not the first thing in <head>") |
| 68 | } |
| 69 | last := -1 |
| 70 | for _, css := range []string{"/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome.", "/static/sheets."} { |
| 71 | i := strings.Index(head, css) |
| 72 | if i < 0 || i < last { |
| 73 | t.Fatalf("%s is missing or out of order", css) |
| 74 | } |
| 75 | last = i |
| 76 | } |
| 77 | for _, asset := range []string{"/static/estilo/base.", "/static/estilo/chrome."} { |
| 78 | href := regexp.MustCompile(regexp.QuoteMeta(asset) + `[0-9a-f]+\.(css|js)`).FindString(head) |
| 79 | if href == "" { |
| 80 | t.Fatalf("%s is not fingerprinted", asset) |
| 81 | } |
| 82 | if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, href, nil)); rec.Code != http.StatusOK || rec.Body.Len() == 0 { |
| 83 | t.Errorf("%s does not serve (%d)", href, rec.Code) |
| 84 | } |
| 85 | } |
| 86 | if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, "/static/estilo/brand/sheets.svg", nil)); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" { |
| 87 | t.Errorf("the bar's brand icon does not serve (%d)", rec.Code) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // §7.3: shortcuts are labelled for the request's platform, server-side, |
| 92 | // in the menus, the toolbar's tips and the shortcuts dialog alike — and |
| 93 | // the page says it varies by User-Agent. |
| 94 | func TestShortcutLabelsFollowThePlatform(t *testing.T) { |
| 95 | app, cookies := signIn(t) |
| 96 | s, _ := newSheet(t, app, cookies, "Budget") |
| 97 | path := "/sheets/" + strconv.FormatUint(s.ID, 10) |
| 98 | for ua, want := range map[string][]string{ |
| 99 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36": {`<kbd estilo-menu-key>⌘Z</kbd>`, `data-tip="Undo (⌘Z)"`, `data-tip="Bold (⌘B)"`, `<dt><kbd>⌘C</kbd></dt>`, `data-key-copy="⌘C"`}, |
| 100 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36": {`<kbd estilo-menu-key>Ctrl+Z</kbd>`, `data-tip="Undo (Ctrl+Z)"`, `data-tip="Bold (Ctrl+B)"`, `<dt><kbd>Ctrl+C</kbd></dt>`, `data-key-copy="Ctrl+C"`}, |
| 101 | } { |
| 102 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 103 | req.Header.Set("User-Agent", ua) |
| 104 | rec := do(t, app, as(req, cookies)) |
| 105 | // html/template writes "+" as +; the browser reads it back. |
| 106 | body := html.UnescapeString(rec.Body.String()) |
| 107 | for _, w := range want { |
| 108 | if !strings.Contains(body, w) { |
| 109 | t.Errorf("%s: missing %s", ua[13:30], w) |
| 110 | } |
| 111 | } |
| 112 | for _, stale := range []string{"Ctrl Z", "(Ctrl+Z)</", "Ctrl + C"} { |
| 113 | if strings.Contains(body, stale) { |
| 114 | t.Errorf("%s: an old label %q survives", ua[13:30], stale) |
| 115 | } |
| 116 | } |
| 117 | if !strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "User-Agent") { |
| 118 | t.Error("a page with platform labels does not Vary on User-Agent") |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // §7.3: an item appears only if it works in this build. The Help menu |
| 124 | // (a list of keys, not a menu) left the menu bar for the account menu's |
| 125 | // "Keyboard shortcuts…" dialog; Format keeps no inline submenus; items |
| 126 | // that need script are hidden until it runs. |
| 127 | func TestUnbuiltMenuItemsAreHidden(t *testing.T) { |
| 128 | app, cookies := signIn(t) |
| 129 | s, _ := newSheet(t, app, cookies, "Budget") |
| 130 | body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| 131 | menubar := body[strings.Index(body, "<nav estilo-menubar"):] |
| 132 | menubar = menubar[:strings.Index(menubar, "</nav>")] |
| 133 | titles := regexp.MustCompile(`<summary estilo-menubar-title>([^<]+)</summary>`).FindAllStringSubmatch(menubar, -1) |
| 134 | var got []string |
| 135 | for _, m := range titles { |
| 136 | got = append(got, m[1]) |
| 137 | } |
| 138 | if strings.Join(got, ",") != "File,Edit,Insert,Format,Data" { |
| 139 | t.Errorf("menu bar titles %v, want File, Edit, Insert, Format, Data", got) |
| 140 | } |
| 141 | if strings.Count(menubar, "<details") != len(titles) { |
| 142 | t.Error("a menu holds a submenu; menus here are one level") |
| 143 | } |
| 144 | for _, gone := range []string{"rst-dropdown", "rst-menu-group", "Fill colour", "Number format"} { |
| 145 | if strings.Contains(menubar, gone) { |
| 146 | t.Errorf("the menu bar still carries %q", gone) |
| 147 | } |
| 148 | } |
| 149 | for _, cmd := range regexp.MustCompile(`<button estilo-menu-item type="button" data-cmd="(cut|copy|paste|clear|fill-down|fill-right|select-all|fn)"[^>]*>`).FindAllString(menubar, -1) { |
| 150 | if !strings.Contains(cmd, " hidden") { |
| 151 | t.Errorf("%s is shown before the script that runs it", cmd) |
| 152 | } |
| 153 | } |
| 154 | // With scripting off Edit holds Undo alone, so its separators are |
| 155 | // script-only too: no rule above an empty group. |
| 156 | edit := menubar[strings.Index(menubar, "<summary estilo-menubar-title>Edit</summary>"):] |
| 157 | edit = edit[:strings.Index(edit, "</details>")] |
| 158 | if n, hidden := strings.Count(edit, "<hr"), strings.Count(edit, "<hr data-needs-js hidden>"); n == 0 || n != hidden { |
| 159 | t.Errorf("Edit has %d separators, %d of them hidden until script: with scripting off they rule off empty groups", n, hidden) |
| 160 | } |
| 161 | // The shortcuts moved to the account menu, as a dialog that exists. |
| 162 | if !strings.Contains(body, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>Keyboard shortcuts…</span></button>`) { |
| 163 | t.Error("the account menu's Help does not open the shortcuts") |
| 164 | } |
| 165 | if !strings.Contains(body, `<dialog id="sheets-shortcuts"`) { |
| 166 | t.Error("the shortcuts dialog the account menu opens is not on the page") |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // §4: the only notice vocabulary is rastrillo's callout with a real tone. |
| 171 | // The grep gate: no rst-notice, no valued rst-callout="…" (the old |
| 172 | // "warn" matched nothing and painted grey), no rst-tone="warn". |
| 173 | func TestTemplatesUseOnlyCalloutTones(t *testing.T) { |
| 174 | files, err := filepath.Glob("templates/*.html") |
| 175 | if err != nil || len(files) == 0 { |
| 176 | t.Fatal("no templates found", err) |
| 177 | } |
| 178 | strip := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`) |
| 179 | bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`) |
| 180 | for _, f := range files { |
| 181 | body, err := os.ReadFile(f) |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if m := bad.FindString(strip.ReplaceAllString(string(body), "")); m != "" { |
| 186 | t.Errorf("%s uses %q", f, m) |
| 187 | } |
| 188 | } |
| 189 | // And the tones reach the page: a created link's warning, a grant's |
| 190 | // positive notice, a refused grant's negative alert. |
| 191 | app, cookies := signIn(t) |
| 192 | s, _ := newSheet(t, app, cookies, "Budget") |
| 193 | share := "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share" |
| 194 | for query, want := range map[string]string{ |
| 195 | "?notice=shared": `rst-tone="positive"`, |
| 196 | "?notice=bad-role": `rst-tone="negative" role="alert"`, |
| 197 | } { |
| 198 | if body := page(t, app, cookies, share+query).Body.String(); !strings.Contains(body, want) { |
| 199 | t.Errorf("%s: no %s callout", query, want) |
| 200 | } |
| 201 | } |
| 202 | rec := do(t, app, as(postForm(share+"/link", map[string][]string{"role": {"viewer"}}), cookies)) |
| 203 | if body := rec.Body.String(); !strings.Contains(body, `<div rst-callout rst-tone="warning">`) || strings.Count(body, "Copy it now") != 1 { |
| 204 | t.Errorf("a new link is not one warning callout (status %d)", rec.Code) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // While a modal dialog is open no page listener may hear a key: static/ |
| 209 | // modal.js stops key and clipboard events at the window, in the capture |
| 210 | // phase, and it has to be the first script so its listener is registered |
| 211 | // before the grid library's (Astra, #39 finding 1; the browser proof is |
| 212 | // tools/drive/modal.mjs, Chromium and WebKit). |
| 213 | func TestTheModalGuardIsTheFirstScript(t *testing.T) { |
| 214 | app, cookies := signIn(t) |
| 215 | s, _ := newSheet(t, app, cookies, "Budget") |
| 216 | body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String() |
| 217 | first := regexp.MustCompile(`<script[^>]*src="([^"]+)"`).FindStringSubmatch(body) |
| 218 | if first == nil || !strings.HasPrefix(first[1], "/static/modal.") { |
| 219 | t.Fatalf("the first script is %v, want static/modal.js", first) |
| 220 | } |
| 221 | js, err := staticFiles().(interface{ ReadFile(string) ([]byte, error) }).ReadFile("static/modal.js") |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | for _, want := range []string{"window.addEventListener(type", "stopImmediatePropagation()", "}, true);", "'keydown'", "'paste'", "dialog:modal"} { |
| 226 | if !strings.Contains(string(js), want) { |
| 227 | t.Errorf("modal.js lost %q", want) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |