test query markup by class token, not exact attribute bytes

Several web tests asserted on exact attribute sequences such as class="outline" value="Begin under override". Those break the moment an attribute is reordered or a second class is added beside the one under test, which happened during this session. Add has_class and has_attr to the test support. has_class reads each class attribute and matches a whitespace-delimited token, so a check survives neighbouring classes and any attribute order, without pulling an HTML parser into the test suite. Migrate every exact class needle to it and cover the helper itself.

Commit
758d13a708949d7e65a07437ac51c3b333c97b23
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
test/web/auth.ml
index 23297bc5..fea3dadd 100644..100644
@@ -17,6 +17,23 @@
17 17 fun () ->
18 18 let c = client () in
19 19 Alcotest.(check int) "GET /login" 200 (status (get c "/login")) );
20 Added: ( "has_class matches a class token regardless of its neighbours",
21 Added: `Quick,
22 Added: fun () ->
23 Added: (* The markup helper the web tests rely on: a class check must
24 Added: survive another class beside it and any attribute order. *)
25 Added: let html =
26 Added: "<a href=\"/x\" class=\"button outline\">Go</a><p \
27 Added: class=\"eyebrow\"></p>"
28 Added: in
29 Added: Alcotest.(check bool) "second token" true (has_class html "outline");
30 Added: Alcotest.(check bool) "first token" true (has_class html "button");
31 Added: Alcotest.(check bool)
32 Added: "other element" true (has_class html "eyebrow");
33 Added: Alcotest.(check bool)
34 Added: "a substring of a token is not a match" false
35 Added: (has_class html "line");
36 Added: Alcotest.(check bool) "absent" false (has_class html "danger") );
20 37 ( "every response carries the browser hardening headers",
21 38 `Quick,
22 39 fun () ->
test/web/logbook.ml
index f3eb976b..2cc64a4c 100644..100644
@@ -21,8 +21,7 @@
21 21 (* No workout table, just the empty-state line. *)
22 22 Alcotest.(check bool)
23 23 "no workout table" false
24 Removed: (Option.is_some
25 Removed: (index_from ~needle:"class=\"logbook-table\"" page 0));
24 Added: (has_class page "logbook-table");
26 25 Alcotest.(check bool)
27 26 "empty state when nothing is logged" true
28 27 (Option.is_some (index_from ~needle:"Nothing logged yet." page 0))
@@ -41,12 +40,10 @@
41 40 workout. There is no table and no Duration. *)
42 41 Alcotest.(check bool)
43 42 "the accordion is present" true
44 Removed: (Option.is_some
45 Removed: (index_from ~needle:"class=\"logbook-entry\"" page 0));
43 Added: (has_class page "logbook-entry");
46 44 Alcotest.(check bool)
47 45 "no workout table" false
48 Removed: (Option.is_some
49 Removed: (index_from ~needle:"class=\"logbook-table\"" page 0));
46 Added: (has_class page "logbook-table");
50 47 Alcotest.(check bool)
51 48 "the entry summary carries the date" true
52 49 (Option.is_some (index_from ~needle:"logbook-entry-date" page 0));
@@ -529,8 +526,7 @@
529 526 This is a first performance, so no change is shown. *)
530 527 Alcotest.(check bool)
531 528 "the entry is an accordion" true
532 Removed: (Option.is_some
533 Removed: (index_from ~needle:"class=\"logbook-entry\"" page 0));
529 Added: (has_class page "logbook-entry");
534 530 Alcotest.(check bool)
535 531 "the imported exercise is listed" true
536 532 (Option.is_some (index_from ~needle:"Squats" page 0));
@@ -766,7 +762,7 @@
766 762 let page = body (get c "/logbook") in
767 763 Alcotest.(check bool)
768 764 "the calendar is present" true
769 Removed: (Option.is_some (index_from ~needle:"class=\"calendar\"" page 0));
765 Added: (has_class page "calendar");
770 766 (* The month and year label is back in the calendar header. *)
771 767 Alcotest.(check bool)
772 768 "the calendar header shows the month name" true
test/web/overview.ml
index be4d9a75..53fe7d1a 100644..100644
@@ -140,8 +140,7 @@
140 140 (index_from ~needle:"value=\"Begin next workout\"" page 0));
141 141 Alcotest.(check bool)
142 142 "the begin submit carries no outline class" false
143 Removed: (Option.is_some (index_from ~needle:"class=\"outline\"" page 0))
144 Removed: );
143 Added: (has_class page "outline") );
145 144 ( "begin under override is an outline blood button while recovering",
146 145 `Quick,
147 146 fun () ->
@@ -163,10 +162,7 @@
163 162 (Option.is_some (index_from ~needle:"requires recovery" page 0));
164 163 Alcotest.(check bool)
165 164 "the override submit is styled as an outline button" true
166 Removed: (Option.is_some
167 Removed: (index_from
168 Removed: ~needle:"class=\"outline\" value=\"Begin under override\""
169 Removed: page 0)) );
165 Added: (has_class page "outline") );
170 166 ( "the routine page is served once a routine is selected",
171 167 `Quick,
172 168 fun () ->
test/web/support.ml
index 419ea2a3..1e098991 100644..100644
@@ -141,6 +141,45 @@
141 141 in
142 142 if start < 0 then None else at start
143 143
144 Added: (* {2 Markup queries}
145 Added:
146 Added: Assertions on rendered HTML should survive an attribute being reordered or a
147 Added: class being added beside the one under test. These helpers match structure
148 Added: rather than an exact byte sequence. They do not parse HTML; they read the
149 Added: [class] attribute of each tag and split it on whitespace, which is all a
150 Added: class check needs and keeps the test suite free of a parser dependency. *)
151 Added:
152 Added: (* Every [class="..."] attribute value in the page, in document order. *)
153 Added: let class_attributes html =
154 Added: let needle = "class=\"" in
155 Added: let rec collect from acc =
156 Added: match index_from ~needle html from with
157 Added: | None -> List.rev acc
158 Added: | Some i -> (
159 Added: let start = i + String.length needle in
160 Added: match String.index_from_opt html start '"' with
161 Added: | None -> List.rev acc
162 Added: | Some stop ->
163 Added: collect (stop + 1) (String.sub html start (stop - start) :: acc))
164 Added: in
165 Added: collect 0 []
166 Added:
167 Added: (* True when some element carries [token] as one of its class names, whatever
168 Added: other classes sit beside it and in whatever order. *)
169 Added: let has_class html token =
170 Added: List.exists
171 Added: (fun value ->
172 Added: List.mem token
173 Added: (String.split_on_char ' ' value |> List.filter (fun s -> s <> "")))
174 Added: (class_attributes html)
175 Added:
176 Added: (* True when some element carries the attribute pair exactly. A single pair
177 Added: matches wherever it sits within its tag, so this is already independent of
178 Added: attribute order. Named for symmetry with [has_class]. *)
179 Added: let has_attr html ~name ~value =
180 Added: Option.is_some
181 Added: (index_from ~needle:(Printf.sprintf "%s=\"%s\"" name value) html 0)
182 Added:
144 183 (* Pull the CSRF token value out of a rendered form. *)
145 184 let csrf_token html =
146 185 match index_from ~needle:"name=\"dream.csrf\"" html 0 with