Repository navigation
Expand file tree
/
Copy pathtemplates_defer_test.go
More file actions
220 lines (208 loc) · 7.48 KB
/
Copy pathtemplates_defer_test.go
File metadata and controls
220 lines (208 loc) · 7.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The CDN libraries are loaded with defer (#624), so none of them is defined
// while the page is still being parsed. Anything an inline script wants to do
// with one has to wait for DOMContentLoaded, which fires only after every
// deferred script has run.
//
// The tests below guard that arrangement, because it breaks silently: the page
// still renders, it just stops highlighting code, or the login button leads
// nowhere, or a save stores the wrong text. A reviewer is unlikely to catch any
// of that by reading a diff.
// templateFiles are the template sets goblog itself ships. Themes carry their
// own copies of header.html, footer.html, post.html and login.html and are
// checked in their own repositories.
func templateFiles(t *testing.T) []string {
t.Helper()
var files []string
for _, glob := range []string{
"templates/shared/*.html",
"themes/default/templates/*.html",
"plugins/*/templates/*.html",
} {
matched, err := filepath.Glob(glob)
if err != nil {
t.Fatalf("glob %s: %v", glob, err)
}
files = append(files, matched...)
}
if len(files) == 0 {
t.Fatal("no templates found; has the layout moved?")
}
return files
}
var scriptSrcTag = regexp.MustCompile(`(?s)<script\b[^>]*\bsrc=[^>]*>`)
// TestExternalScriptsAreDeferred: a <script src> without defer blocks the
// parser until it has been fetched and run.
func TestExternalScriptsAreDeferred(t *testing.T) {
for _, path := range templateFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, tag := range scriptSrcTag.FindAllString(string(body), -1) {
if !strings.Contains(tag, " defer") {
t.Errorf("%s: <script src> is missing defer:\n\t%s", path, tag)
}
}
}
}
var (
inlineScript = regexp.MustCompile(`(?s)<script\b([^>]*)>(.*?)</script>`)
// A statement that opens a DOMContentLoaded handler. Either target works and
// either quote style does, so the lint does not also police style.
readyGuard = regexp.MustCompile(`^(?:document|window)\.addEventListener\(\s*["']DOMContentLoaded["']`)
// Globals that only exist once the deferred scripts have run. admin-script.js
// is deferred too, so setupEditorTabs belongs here as much as jQuery does.
deferredGlobals = []string{
"$(", "$.", "jQuery", "hljs", "SimpleMDE", "inlineAttachment",
"DOMPurify", "moment(", "bootstrap.", "setupEditorTabs",
}
)
// TestInlineScriptsWaitForDeferredLibraries: an inline script runs during
// parsing, before any deferred script. It may *declare* a function that uses a
// library — onclick handlers call those long after load, and they have to stay
// global — but it must not *call* into one at the top level.
func TestInlineScriptsWaitForDeferredLibraries(t *testing.T) {
for _, path := range templateFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, m := range inlineScript.FindAllStringSubmatch(string(body), -1) {
attrs, script := m[1], m[2]
if strings.Contains(attrs, "src=") || strings.Contains(attrs, "ld+json") {
continue
}
for _, stmt := range topLevelStatements(script) {
if readyGuard.MatchString(stmt) {
continue
}
for _, global := range deferredGlobals {
if strings.Contains(stmt, global) {
t.Errorf("%s: inline script uses %s while the page is still "+
"parsing, before the deferred libraries have run. Move it "+
"into a DOMContentLoaded handler:\n\t%s", path, global, stmt)
break
}
}
}
}
}
}
// topLevelStatements returns the lines of an inline script that sit outside
// every brace and bracket — the code that runs as the parser reaches it, as
// opposed to a function body that runs later. Strings and comments are skipped
// so their punctuation does not throw the depth off. Go template actions are
// dropped for the same reason; a regex literal containing an unbalanced brace
// would still confuse it, but none of the templates has one.
func topLevelStatements(script string) []string {
script = regexp.MustCompile(`(?s)\{\{.*?\}\}`).ReplaceAllString(script, "0")
var out []string
depth := 0
for _, line := range strings.Split(script, "\n") {
trimmed := strings.TrimSpace(line)
if depth == 0 && trimmed != "" && !strings.HasPrefix(trimmed, "//") {
out = append(out, trimmed)
}
depth += lineDepth(line)
}
return out
}
// lineDepth is the net change in nesting a line makes, ignoring anything
// inside a string literal or a comment.
func lineDepth(line string) int {
depth := 0
var quote byte
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case quote != 0:
if c == '\\' {
i++
} else if c == quote {
quote = 0
}
case c == '\'' || c == '"' || c == '`':
quote = c
case c == '/' && i+1 < len(line) && line[i+1] == '/':
return depth
case c == '{' || c == '(' || c == '[':
depth++
case c == '}' || c == ')' || c == ']':
depth--
}
}
return depth
}
// TestEditorTemplatesKeepSimplemdeGlobal: admin-script.js reads `simplemde` as
// a global — updatePost and createPost send simplemde.value() on save, and
// setupEditorTabs reads it to render the Preview tab. Moving the editor's
// construction into a DOMContentLoaded handler (#624) makes it easy to leave
// `var simplemde = new SimpleMDE(...)` inside the handler, which scopes it to
// the handler. Nothing throws: saving just starts sending the hidden
// textarea's stale text instead of what was typed.
func TestEditorTemplatesKeepSimplemdeGlobal(t *testing.T) {
for _, path := range templateFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
script := string(body)
if !strings.Contains(script, "new SimpleMDE") {
continue
}
var declaredAtTopLevel bool
for _, m := range inlineScript.FindAllStringSubmatch(script, -1) {
if strings.Contains(m[1], "src=") {
continue
}
for _, stmt := range topLevelStatements(m[2]) {
if stmt == "var simplemde;" || strings.HasPrefix(stmt, "var simplemde =") {
declaredAtTopLevel = true
}
}
}
if !declaredAtTopLevel {
t.Errorf("%s builds a SimpleMDE editor but does not declare `simplemde` at "+
"the top level of an inline script. admin-script.js reads it as a global; "+
"scoped to a handler, saving silently sends stale content.", path)
}
}
}
// TestReadyGuardAcceptsCommonForms pins which statements count as "this waits
// for DOMContentLoaded", so the lint does not quietly become a style rule. Both
// targets and both quote styles are equivalent; jQuery's own ready helpers are
// not, because they need jQuery to already be defined.
func TestReadyGuardAcceptsCommonForms(t *testing.T) {
accepted := []string{
`document.addEventListener("DOMContentLoaded", function () {`,
`document.addEventListener('DOMContentLoaded', function () {`,
`window.addEventListener("DOMContentLoaded", () => {`,
`document.addEventListener( "DOMContentLoaded", init);`,
}
rejected := []string{
`$(function () {`,
`$(document).ready(function () {`,
`jQuery(document).ready(function () {`,
`hljs.highlightAll();`,
`el.addEventListener("DOMContentLoaded", fn);`, // not document/window
`init(); document.addEventListener("DOMContentLoaded", fn);`,
}
for _, stmt := range accepted {
if !readyGuard.MatchString(stmt) {
t.Errorf("should be accepted as a DOMContentLoaded guard: %s", stmt)
}
}
for _, stmt := range rejected {
if readyGuard.MatchString(stmt) {
t.Errorf("should not count as a DOMContentLoaded guard: %s", stmt)
}
}
}