package web
import (
"os"
"regexp"
"strings"
"testing"
)
// consequential lists the data-confirm controls that are NOT
// destructive, keyed by a distinctive phrase of their own confirm
// text. Each one is a deliberate decision, not an oversight:
//
// - joining a team GRANTS reach; it removes nothing.
// - rotating a secret shows a new one while the current keeps
// working, so nothing is lost at the moment of pressing.
// - resetting a mark returns the generated one; the picture can be
// uploaded again.
//
// They still confirm, because they surprise; they do not warn, because
// warning everything is warning nothing.
var consequential = []string{
"joins {{$.Team.Team.Name}}",
"A new signing secret is shown once",
"goes back to the mark drawn from its handle",
"goes back to the mark drawn from its name",
"You go back to the mark drawn from your name",
}
// Every irreversible control reads as irreversible.
//
// data-confirm is this codebase's own marker for "this will surprise
// somebody", so it is the honest place to look. Before this, NONE of
// the nineteen carried a danger class: fourteen had no class at all
// and five had plain btn, so discarding a ticket, removing a webhook
// and its secret, revoking a token and unregistering a repository all
// looked exactly like Save sitting beside them. btn-danger existed,
// but only inside `.dt td.dt-act`, which is one session table.
//
// The point of scanning rather than listing: a NEW destructive control
// cannot ship unnoticed. It either carries the class or it is argued
// onto the consequential list above, and both are decisions somebody
// made on purpose.
func TestEveryConfirmingControlDeclaresItself(t *testing.T) {
tag := regexp.MustCompile(`<(?:button|a)\b[^>]*`)
for _, name := range []string{"web.go", "attach.go", "prefs.go"} {
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(src), "\n")
for i, line := range lines {
if !strings.Contains(line, "data-confirm=") {
continue
}
if skip := func() bool {
for _, c := range consequential {
if strings.Contains(line, c) {
return true
}
}
return false
}(); skip {
continue
}
// The opening tag sits on this line or the one above it.
found := false
for _, cand := range []string{line, at(lines, i-1)} {
if m := tag.FindAllString(cand, -1); len(m) > 0 {
if strings.Contains(m[len(m)-1], "btn-danger") {
found = true
}
}
if found {
break
}
}
if !found {
t.Errorf("FAIL: %s:%d — a control that asks for confirmation does not read "+
"as destructive. Give it btn-danger, or argue it onto `consequential` "+
"in this file.\n %s", name, i+1, snippet(line))
}
}
}
}
// And the convention is one class, defined once, not per component
// family. The chat menu forked its own danger colour against a
// custom property that is defined NOWHERE, so it fell through to a
// hardcoded hex — not theme-aware, not the forge's danger red, and
// outranking the --del rule below it on specificity.
func TestDangerColourComesFromTheTokenNotAHex(t *testing.T) {
// The sheet itself, not a source file: these rules live in
// stylesheet.go now, and a guard that names a filename fails on a
// move that changes nothing about the rule.
s := styleSource
if strings.Contains(s, "var(--bad") {
t.Error("FAIL: a danger rule reads --bad, which is defined nowhere; it renders " +
"its hardcoded fallback in both themes. Use --del.")
}
if !strings.Contains(s, ".btn-danger { color: var(--del)") {
t.Error("FAIL: btn-danger does not take its colour from --del")
}
}
func at(lines []string, i int) string {
if i < 0 || i >= len(lines) {
return ""
}
return lines[i]
}
func snippet(s string) string {
s = strings.TrimSpace(s)
if len(s) > 120 {
return s[:120] + "…"
}
return s
}
// In a row of actions, the destructive one comes last.
//
// The webhook row shipped five controls as Save, Pause, Remove, Ping,
// Rotate secret: the irreversible one third of five, between two
// harmless neighbours, with nothing to distinguish it. A misclick
// there loses a secret for good. Order is the cheapest part of the
// fix, and unlike colour it survives a reader who cannot see colour.
func TestADestructiveControlComesLastInItsRow(t *testing.T) {
src := webSource(t)
for _, m := range regexp.MustCompile(`(?s)<form\b[^>]*>.*?</form>`).FindAllString(src, -1) {
btns := regexp.MustCompile(`<button\b[^>]*`).FindAllString(m, -1)
if len(btns) < 3 {
continue
}
for i, b := range btns {
if !strings.Contains(b, "btn-danger") {
continue
}
if i != len(btns)-1 {
t.Errorf("FAIL: a destructive control sits at position %d of %d in a row; "+
"put it last so it is not pressed by aiming at a neighbour.\n %s",
i+1, len(btns), strings.Join(strings.Fields(b), " ")[:90])
}
}
}
}
// A row of several controls de-emphasises its utilities.
//
// Five identical neutral buttons offer no answer to "what is this row
// for". The fix is not a primary: this row repeats once per webhook,
// and a primary per row is several primaries per page, while the rule
// is one per VIEW. So the utilities go quiet instead, which lifts the
// ordinary actions without adding emphasis to the page.
func TestARowOfActionsDeEmphasisesItsUtilities(t *testing.T) {
src := webSource(t)
for _, m := range regexp.MustCompile(`(?s)<form\b[^>]*>.*?</form>`).FindAllString(src, -1) {
if len(regexp.MustCompile(`<button\b`).FindAllString(m, -1)) < 5 {
continue
}
// The class on a control, not the word anywhere: a template
// comment explaining WHY this row carries no primary contains
// the word, and counting substrings made the comment fail the
// test it was explaining.
cls := func(name string) int {
return len(regexp.MustCompile(`<button\b[^>]*class="[^"]*\b`+name+`\b`).FindAllString(m, -1))
}
if n := cls("btn-quiet"); n < 2 {
t.Errorf("FAIL: a row of five controls marks %d of them quiet, so a rarely-used "+
"control competes with the one the row is for", n)
}
// And it does not answer the problem with emphasis it cannot
// afford: a repeated row must not carry a primary.
if cls("btn-primary") > 0 {
t.Error("FAIL: a repeated row carries a primary, which becomes one per webhook " +
"on the page; the rule is one primary per view")
}
}
}