Repository navigation
Expand file tree
/
Copy pathmain.dang
More file actions
1653 lines (1534 loc) · 61.1 KB
/
Copy pathmain.dang
File metadata and controls
1653 lines (1534 loc) · 61.1 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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
pub description = "Run Playwright browser tests against your project, with workspace service wiring"
# Fallback image when the project's @playwright/test version can't be derived.
let fallbackImage = "mcr.microsoft.com/playwright:v1.58.2-noble"
# Basenames that mark a Playwright project directory.
let configNames: [String!]! = [
"playwright.config.ts",
"playwright.config.js",
"playwright.config.mjs",
"playwright.config.cjs",
"playwright.config.mts",
"playwright.config.cts",
]
# range returns [1, 2, ..., n]
let range(n: Int!): [Int!]! {
if (n <= 0) {
[] :: [Int!]!
} else {
range(n - 1) + [n]
}
}
# sorted orders strings bytewise, so keys (and their cache keys) are stable.
let sorted(items: [String!]!): [String!]! {
let empty: [String!]! = []
items.reduce(empty) { acc, item =>
acc.takeWhile { x => x <= item } + [item] + acc.dropWhile { x => x <= item }
}
}
# rootPath resolves a path — cwd-relative, or absolute from the workspace
# root — to a workspace-root-relative path ("." is the workspace root).
let rootPath(cwd: String!, p: String!): String! {
let start = if (p.hasPrefix("/")) {
[] :: [String!]!
} else {
cwd.split("/").filter { s => s != "" and s != "." }
}
let segments = p.split("/").reduce(start) { acc, seg =>
if (seg == "" or seg == ".") {
acc
} else if (seg == "..") {
acc.dropLast(1)
} else {
acc + [seg]
}
}
if (segments.length == 0) { "." } else { segments.join("/") }
}
# wsPath turns a root-relative path into the absolute form the Workspace API
# resolves from the workspace root.
let wsPath(p: String!): String! {
if (p == ".") { "/" } else { "/" + p }
}
# appPath is where a root-relative path lives inside the test container.
let appPath(p: String!): String! {
if (p == ".") { "/app" } else { "/app/" + p }
}
# dirOf returns the root-relative directory of an absolute workspace path
# returned by findUp ("/fixture/package.json" → "fixture").
let dirOf(found: String!): String! {
let dir = found.trimPrefix("/").split("/").dropLast(1).join("/")
if (dir == "") { "." } else { dir }
}
# depth counts a root-relative path's segments; the root "." has none.
let depth(p: String!): Int! {
if (p == ".") { 0 } else { p.split("/").length }
}
# Static reading of playwright.config: only literal values are honoured, and
# nothing here runs a container or Node. Offsets are byte offsets, so the
# source is made ASCII first.
# Playwright's default testMatch.
let defaultTestMatch = "**/*.@(spec|test).?(c|m)[jt]s?(x)"
# A JS string literal: single, double, or backtick without interpolation.
let stringLiteral = `'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|` + "`" + `(?:[^` + "`" + `$\\]|\\[\s\S])*` + "`"
# A JS regex literal body and flags.
let regexLiteral = `/(?:[^/\\\n\[*]|\\.|\[(?:[^\]\\\n]|\\.)*\])(?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])*/[a-z]*`
# One literal pattern: a string (a glob) or a regex literal.
let patternLiteral = stringLiteral + "|" + regexLiteral
# Comments, strings and regex literals: everything whose brackets and commas
# are not code. Template strings are masked whole, ${} parts included. A
# regex literal is recognized only after a character that can precede an
# expression, captured so masking keeps it.
let tokenPattern = `//[^\n]*|/\*[\s\S]*?\*/|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|` + "`" + `(?:[^` + "`" + `\\]|\\[\s\S])*` + "`" + `|([:\[,(=!&|?]\s*)` + regexLiteral
# A run of n filler characters.
let fill(n: Int!, char: String!): String! {
"".padRight(n).replace(" ", char)
}
# Byte length of an ASCII string.
let size(s: String!): Int! { s.split("").length }
# The bytes of s in [start, end); s must be ASCII.
let slice(s: String!, start: Int!, end: Int!): String! {
s.truncate(end).trimPrefix(s.truncate(start))
}
# Replace comments, strings and regex literals with filler of the same length,
# so offsets line up with the source while their brackets and commas vanish.
let mask(source: String!): String! {
source.rewriteMatches(Regexp(tokenPattern)) { m =>
let lead = m.captures[0] ?? ""
# Comments read as whitespace; literals as an opaque word.
let char = if (m.string.hasPrefix("/")) { " " } else { "_" }
lead + fill(m.end - m.start - size(lead), char)
}
}
# Replace every innermost bracketed group with filler of the same length,
# keeping a marker for its opening bracket: "~" for {, "^" for [, "%" for (.
let flattenOnce(masked: String!): String! {
masked.rewriteMatches(`[{\[(][^{}\[\]()]*[}\])]`) { m =>
let open = if (m.string.hasPrefix("{")) { "~" } else if (m.string.hasPrefix("[")) { "^" } else { "%" }
open + fill(m.end - m.start - 1, "_")
}
}
# The group opening at offset start, once everything nested in it has been
# flattened: its own top level is then plain text. Null when there is none.
let groupAt(masked: String!, start: Int!): String {
let hits = masked.matchAll(`[{\[(][^{}\[\]()]*[}\])]`).filter { m => m.start == start }
let hit = hits[0]
if (hit != null) {
return hit.string
}
let next = flattenOnce(masked)
if (next == masked) {
return null
}
groupAt(next, start)
}
# A string literal's value, or null when text is anything else.
let stringValue(text: String!): String {
let m = text.trimSpace.match(Regexp(`^(?:'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|` + "`" + `([^` + "`" + `$\\]*)` + "`" + `)$`))
if (m == null) {
return null
}
m.captures.join("").replaceMatches(`\\(.)`, "$1")
}
# A testDir value: a string literal (relative to the config directory), or
# path.join/path.resolve of __dirname and string literals, returned as
# "__dirname/<path>". Null otherwise.
let dirValue(text: String!): String {
let plain = stringValue(text)
if (plain != null) {
return plain
}
let call = text.trimSpace.match(
Regexp(`^(?:path\.)?(?:join|resolve)\(\s*(?:__dirname|import\.meta\.dirname)((?:\s*,\s*(?:` + stringLiteral + `))+)\s*,?\s*\)$`),
)
if (call == null) {
return null
}
let parts = (call.captures[0] ?? "").matchAll(Regexp(stringLiteral)).map { p => stringValue(p.string) ?? "" }
"__dirname/" + parts.join("/")
}
# A literal pattern as a regexp over absolute paths, or null when it cannot
# be translated: a string is a glob, /.../flags a regex literal.
let patternRegex(text: String!): String {
let glob = stringValue(text)
if (glob != null) {
return globRegex(glob)
}
let re = text.trimSpace.match(`^/([\s\S]*)/([a-z]*)$`)
if (re == null) {
return null
}
let flags = re.captures[1] ?? ""
let prefix = ["i", "m", "s"].filter { f => flags.contains(f) }.join("")
let pattern = (if (prefix == "") { "" } else { "(?" + prefix + ")" }) + (re.captures[0] ?? "")
# RE2 has no lookaround or backreferences; such a regex falls back.
let ok = { Regexp(pattern); true } rescue {
err: Error => false
}
if (ok) { pattern } else { null }
}
# A testMatch/testIgnore value as regexps: a literal pattern or an array of
# them. Null when the value is anything else, or any element can't be read.
let patternsValue(text: String!): [String!] {
let trimmed = text.trimSpace
let items = if (trimmed.hasPrefix("[") and trimmed.hasSuffix("]")) {
let inner = trimmed.trimPrefix("[").trimSuffix("]")
let element = Regexp(`\s*(?:` + patternLiteral + `)\s*(?:,|$)`)
if (inner.replaceMatches(element, with: "").trimSpace != "") {
return null
}
inner.matchAll(element).map { m => m.string.trimSpace.trimSuffix(",") }
} else {
[trimmed]
}
let regexes = items.map { item => patternRegex(item) ?? "" }
if (regexes.contains("")) { null } else { regexes }
}
# A glob in minimatch syntax (nocase, dot) as a regexp over absolute paths,
# matched like Playwright does: a pattern not starting with **/ gets one.
# Null for syntax this does not translate (!(...)).
let globRegex(glob: String!): String {
let tokens = glob.matchAll(
`\*\*/|\*\*|[@?+*!]\(|[{}()|,*?/]|\[(?:\\.|[^\]\\])+\]|\\.|[^*?\[\]{}()|,@+!\\/]+|[\s\S]`,
).map { m => m.string }
let empty: [String!]! = []
let state = tokens.reduce({{out: "", stack: empty, bad: false}}) { st, tok =>
let top = st.stack.takeLast(1)[0] ?? ""
let inBrace = top == "{"
let inExt = top != "" and top != "{"
if (tok == "**/") {
{{out: st.out + "(?:.*/)?", stack: st.stack, bad: st.bad}}
} else if (tok == "**") {
{{out: st.out + ".*", stack: st.stack, bad: st.bad}}
} else if (tok == "*") {
{{out: st.out + "[^/]*", stack: st.stack, bad: st.bad}}
} else if (tok == "?") {
{{out: st.out + "[^/]", stack: st.stack, bad: st.bad}}
} else if (tok == "!(") {
{{out: st.out, stack: st.stack, bad: true}}
} else if (tok == "@(" or tok == "?(" or tok == "+(" or tok == "*(") {
{{
out: st.out + "(?:",
stack: st.stack + [tok.trimSuffix("(")],
bad: st.bad
}}
} else if (tok == "{") {
{{out: st.out + "(?:", stack: st.stack + ["{"], bad: st.bad}}
} else if (tok == "}" and inBrace) {
{{out: st.out + ")", stack: st.stack.dropLast(1), bad: st.bad}}
} else if (tok == "," and inBrace) {
{{out: st.out + "|", stack: st.stack, bad: st.bad}}
} else if (tok == "|" and inExt) {
{{out: st.out + "|", stack: st.stack, bad: st.bad}}
} else if (tok == ")" and inExt) {
let suffix = if (top == "@") { "" } else { top }
{{out: st.out + ")" + suffix, stack: st.stack.dropLast(1), bad: st.bad}}
} else if (tok.hasPrefix("[") and tok.hasSuffix("]") and size(tok) > 2) {
let body = tok.trimPrefix("[").trimSuffix("]")
let negated = if (body.hasPrefix("!")) { "^" + body.trimPrefix("!") } else { body }
{{out: st.out + "[" + negated + "]", stack: st.stack, bad: st.bad}}
} else if (tok.hasPrefix("\\") and size(tok) == 2) {
{{
out: st.out + Regexp.escape(tok.trimPrefix("\\")),
stack: st.stack,
bad: st.bad
}}
} else {
{{out: st.out + Regexp.escape(tok), stack: st.stack, bad: st.bad}}
}
}
if (state.bad or state.stack.length > 0) {
return null
}
let body = if (glob.hasPrefix("**/")) { state.out } else { "(?:.*/)?" + state.out }
let pattern = "(?i)^" + body + "$"
let ok = { Regexp(pattern); true } rescue {
err: Error => false
}
if (ok) { pattern } else { null }
}
# What a config says about one field: "absent", "literal" (with values), or
# "dynamic" (set to something this can't read).
let field(state: String!, values: [String!]!): {{state: String!, values: [String!]!}}! {
{{state, values}}
}
# The source with non-ASCII characters replaced, so byte offsets and
# character offsets agree.
let ascii(source: String!): String! {
source.replaceMatches(`[^\x00-\x7F]`, with: "?")
}
# The source with comments blanked and everything else intact.
let uncomment(source: String!): String! {
source.rewriteMatches(Regexp(tokenPattern)) { m =>
if (m.string.hasPrefix("/")) { fill(m.end - m.start, " ") } else { m.string }
}
}
# testDir, testMatch and testIgnore of the object at start. testDir values
# are relative to the config directory, or "@<workspace path>" when written
# with __dirname (which is the file's own directory, fileDir).
let fieldsAt(source: String!, masked: String!, start: Int!, fileDir: String!): {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}! {
let body = groupAt(masked, start) ?? ""
let length = size(body)
# Keys at this object's top level; nested objects are flattened, so their
# keys don't match.
let props = body.matchAll(`[{,]\s*(\w+)\s*:`)
let read(name: String!, parse: String!): {{state: String!, values: [String!]!}}! {
# A key without a value (shorthand) is a variable.
if (body.containsMatch(Regexp(`[{,]\s*` + name + `\s*[,}]`))) {
return field("dynamic", [])
}
let hit = props.filter { m => (m.captures[0] ?? "") == name }.takeLast(1)[0]
if (hit == null) {
return field("absent", [])
}
let value = slice(body, hit.end, length).match(`^[^,}]*`)
let text = slice(source, start + hit.end, start + hit.end + (if (value == null) { 0 } else { value.end }))
let values = if (parse == "dir") {
let dir = dirValue(text)
if (dir == null) {
null
} else if (dir.hasPrefix("__dirname/")) {
["@" + rootPath(fileDir, dir.trimPrefix("__dirname/"))]
} else {
[dir]
}
} else {
patternsValue(text)
}
if (values == null) { field("dynamic", []) } else { field("literal", values) }
}
{{
testDir: read("testDir", "dir"),
testMatch: read("testMatch", "patterns"),
testIgnore: read("testIgnore", "patterns")
}}
}
# Everything read from one config object: its fields, its Playwright
# projects ("absent", "literal" with entries, or "dynamic"), and the first
# identifier spread into it ("" for none).
let objectSettings(
source: String!,
masked: String!,
start: Int!,
fileDir: String!,
): {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}! {
let own = fieldsAt(source, masked, start, fileDir)
let body = groupAt(masked, start) ?? ""
let length = size(body)
let hit = body.matchAll(`[{,]\s*projects\s*:\s*`).takeLast(1)[0]
let at = if (hit == null) { -1 } else { start + hit.end }
let isArray = at >= 0 and slice(masked, at, at + 1) == "["
let entries = if (isArray) {
let array = groupAt(masked, at) ?? ""
array
.matchAll(`~_*`)
.map { m => at + m.start }
.filter { entry => slice(masked, entry, entry + 1) == "{" }
.map { entry => fieldsAt(source, masked, entry, fileDir) }
} else {
[]
}
let projectsState = if (hit == null and body.containsMatch(`[{,]\s*projects\s*[,}]`) == false) {
"absent"
} else if (isArray and entries.length > 0) {
"literal"
} else if (isArray) {
"absent"
} else {
"dynamic"
}
let spread = body.match(`[{,]\s*\.\.\.\s*(\w+)\s*[,}]`)
{{
testDir: own.testDir,
testMatch: own.testMatch,
testIgnore: own.testIgnore,
projectsState,
projects: entries,
spread: if (spread == null) { "" } else { spread.captures[0] ?? "" }
}}
}
# Settings of an object that has nothing literal in it.
let noSettings: {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}! =
objectSettings("{}", "{}", 0, ".")
# over's settings on top of base's, as a spread followed by local keys does.
let mergeSettings(
base: {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}!,
over: {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}!,
): {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}! {
let keep = over.projectsState == "absent"
{{
testDir: if (over.testDir.state == "absent") { base.testDir } else { over.testDir },
testMatch: if (over.testMatch.state == "absent") { base.testMatch } else { over.testMatch },
testIgnore: if (over.testIgnore.state == "absent") { base.testIgnore } else { over.testIgnore },
projectsState: if (keep) { base.projectsState } else { over.projectsState },
projects: if (keep) { base.projects } else { over.projects },
spread: ""
}}
}
# Where a file's config object is: "object" at an offset in the file,
# "import" of an export (name) from a relative module (from), or "none".
let target(kind: String!, at: Int!, name: String!, from: String!): {{kind: String!, at: Int!, name: String!, from: String!}}! {
{{kind, at, name, from}}
}
# The module an identifier is imported from, as a target, or kind "none".
let importOf(clean: String!, ident: String!): {{kind: String!, at: Int!, name: String!, from: String!}}! {
let imports = clean.matchAll(
`import\s+(?:type\s+)?(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]`,
)
let found = imports.reduce(target("none", 0, "", "")) { acc, m =>
let from = m.captures[2] ?? ""
if (acc.kind != "none") {
acc
} else if ((m.captures[0] ?? "") == ident) {
target("import", 0, "default", from)
} else {
let named = (m.captures[1] ?? "").split(",").map { part => part.trimSpace }.filter { part =>
part == ident or part.hasSuffix(" " + ident)
}[0]
if (named == null) {
acc
} else {
target("import", 0, named.split(" ")[0] ?? ident, from)
}
}
}
found
}
# Where the object an identifier names is: a const in this file, or an import.
let identTarget(masked: String!, clean: String!, ident: String!): {{kind: String!, at: Int!, name: String!, from: String!}}! {
let local = masked.match(
Regexp(`(?:const|let|var)\s+` + ident + `\s*(?::[^=;]*)?=\s*(?:defineConfig\s*\(\s*)?\{`),
)
if (local != null) {
return target("object", local.end - 1, "", "")
}
importOf(clean, ident)
}
# Where a config file's default export is.
let configTarget(source: String!): {{kind: String!, at: Int!, name: String!, from: String!}}! {
let masked = mask(source)
let clean = uncomment(source)
let direct = masked.match(`(?:defineConfig\s*\(\s*|export\s+default\s+|module\.exports\s*=\s*)\{`)
if (direct != null) {
return target("object", direct.end - 1, "", "")
}
let reexport = clean.match(`export\s*\{\s*(\w+)\s+as\s+default\s*\}\s*from\s*['"]([^'"]+)['"]`)
if (reexport != null) {
return target("import", 0, reexport.captures[0] ?? "", reexport.captures[1] ?? "")
}
let passthrough = clean.match(`export\s*\{\s*default\s*\}\s*from\s*['"]([^'"]+)['"]`)
if (passthrough != null) {
return target("import", 0, "default", passthrough.captures[0] ?? "")
}
let named = masked.match(`(?:export\s+default\s+|module\.exports\s*=\s*)(?:defineConfig\s*\(\s*)?(\w+)`)
if (named != null) {
return identTarget(masked, clean, named.captures[0] ?? "")
}
target("none", 0, "", "")
}
# Where a module's export of name is, without following further imports.
let exportTarget(source: String!, name: String!): {{kind: String!, at: Int!, name: String!, from: String!}}! {
if (name == "default") {
let found = configTarget(source)
return if (found.kind == "object") { found } else { target("none", 0, "", "") }
}
let local = identTarget(mask(source), uncomment(source), name)
if (local.kind == "object") { local } else { target("none", 0, "", "") }
}
# The Playwright projects settings describe, as (testDir as a workspace path,
# testMatch regexps, testIgnore regexps). Absent values inherit from the
# config's top level, then Playwright's defaults; dynamic ones take the
# defaults.
let specsOf(
settings: {{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!, projectsState: String!, projects: [{{testDir: {{state: String!, values: [String!]!}}!, testMatch: {{state: String!, values: [String!]!}}!, testIgnore: {{state: String!, values: [String!]!}}!}}!]!, spread: String!}}!,
configDir: String!,
): [{{dir: String!, match: [String!]!, ignore: [String!]!}}!]! {
let defaultMatch: [String!]! = [globRegex(defaultTestMatch) ?? ""]
let empty: [String!]! = []
let dirOfValue(value: String!): String! {
if (value.hasPrefix("@")) {
value.trimPrefix("@")
} else if (value.hasPrefix("/")) {
configDir
} else {
rootPath(configDir, value)
}
}
let resolve(
value: {{state: String!, values: [String!]!}}!,
inherited: [String!]!,
fallback: [String!]!,
): [String!]! {
if (value.state == "literal") { value.values } else if (value.state == "dynamic") { fallback } else { inherited }
}
let dir = dirOfValue(resolve(settings.testDir, ["."], ["."])[0] ?? ".")
let match = resolve(settings.testMatch, defaultMatch, defaultMatch)
let ignore = resolve(settings.testIgnore, empty, empty)
if (settings.projectsState != "literal") {
return [{{dir, match, ignore}}]
}
settings.projects.map { own =>
{{
dir: if (own.testDir.state == "literal") {
dirOfValue(own.testDir.values[0] ?? ".")
} else if (own.testDir.state == "dynamic") {
configDir
} else {
dir
},
match: resolve(own.testMatch, match, defaultMatch),
ignore: resolve(own.testIgnore, ignore, empty)
}}
}
}
# Every relative module path an import specifier could name, most likely
# first (TypeScript sources import "./x.js" for "./x.ts").
let moduleCandidates(fromDir: String!, spec: String!): [String!]! {
if (spec.hasPrefix("./") == false and spec.hasPrefix("../") == false) {
return []
}
let base = rootPath(fromDir, spec)
let known = base.containsMatch(`\.(?:[cm]?[jt]s)$`)
if (known) {
[base, base.replaceMatches(`\.js$`, with: ".ts"), base.replaceMatches(`\.mjs$`, with: ".mts")].uniq
} else {
[base + ".ts", base + ".js", base + ".mjs", base + ".mts", base + ".cjs", base + "/index.ts", base + "/index.js"]
}
}
# relPath is to, relative to from; both are root-relative directories/files.
let relPath(from: String!, to: String!): String! {
if (from == ".") {
return to
}
if (to.hasPrefix(from + "/")) {
return to.trimPrefix(from + "/")
}
let a = from.split("/")
let b = if (to == ".") { [] :: [String!]! } else { to.split("/") }
let common = a.reduce({{n: 0, same: true}}) { st, seg =>
if (st.same and (b[st.n] ?? "") == seg) {
{{n: st.n + 1, same: true}}
} else {
{{n: st.n, same: false}}
}
}
let up = range(a.length - common.n).map { i => ".." }
(up + b.dropFirst(common.n)).join("/")
}
# under reports whether a root-relative path is dir or inside it.
let under(dir: String!, p: String!): Boolean! {
dir == "." or p == dir or p.hasPrefix(dir + "/")
}
# splitInto divides items into at most count contiguous groups whose sizes
# differ by at most one, keeping their order.
let splitInto(items: [String!]!, count: Int!): [[String!]!]! {
let n = items.length
let groups = if (count < n) { count } else { n }
if (groups <= 0) {
return [] :: [[String!]!]!
}
let size = n / groups
let extra = n % groups
range(groups).map { k =>
let i = k - 1
let start = i * size + (if (i < extra) { i } else { extra })
let length = size + (if (i < extra) { 1 } else { 0 })
items.dropFirst(start).takeFirst(length)
}
}
"""
Run Playwright browser tests against the projects in a workspace.
"""
type Playwright {
"""
Base container override. When null, the image is derived from each
project's @playwright/test version.
"""
baseCtr: Container
"""
Service under test, bound into the test container as `serviceHostname`.
"""
service: Service
"""
Hostname the service is bound as inside the test container.
"""
serviceHostname: String!
"""
Playwright image to run tests in. Empty derives it per project.
"""
baseImageAddress: String!
"""
Package manager used to install dependencies; empty detects it per project.
"""
packageManager: String!
"""
Extra arguments for the install command.
"""
installFlags: [String!]!
"""
Environment variables for the test containers, as KEY=VALUE.
"""
environment: [String!]!
"""
Whether localhost:<port> is proxied to the bound service.
"""
localhostProxy: Boolean!
"""
Extra arguments passed to every `playwright test` invocation.
"""
args: [String!]!
"""
Playwright reporters every run uses, alongside the telemetry reporter.
"""
reporters: [String!]!
"""
Number of parallel shards each project's tests run in.
"""
shards: Int!
"""
Shell commands run in the project directory after the full source is in
place and before the tests.
"""
setup: [String!]!
new(
"""
Base container to use instead of the derived Playwright image
(wireable: settings.baseCtr = "<module>:<function>" for any Container-returning function).
`npx playwright` must work inside it after dependency install.
"""
baseCtr: Container,
"""
Service to run tests against
(wireable: settings.service = "<module>:<function>" for any Service-returning function).
Bound as `serviceHostname`; PLAYWRIGHT_BASE_URL is set to its first exposed port.
Omit it when your playwright.config uses its own webServer.
"""
service: Service,
"""
Hostname the service is bound as inside the test container.
"""
serviceHostname: String! = "frontend",
"""
Playwright image to run tests in. Empty (default) derives
mcr.microsoft.com/playwright:v<version>-noble from each project's
@playwright/test dependency. The image's browsers must match that version.
"""
baseImageAddress: String! = "",
"""
Package manager used to install dependencies (npm, yarn, pnpm, bun).
Empty (default) detects it from the install root's package.json
"packageManager" field, else its lockfile, else npm.
"""
packageManager: String! = "",
"""
Extra arguments appended to the install command (e.g. ["--ignore-scripts"]).
"""
installFlags: [String!]! = [],
"""
Environment variables set in the test containers, as KEY=VALUE
(e.g. ["DEBUG=pw:api"]).
"""
environment: [String!]! = [],
"""
Proxy localhost:<port> to the bound service via socat, and set
PLAYWRIGHT_LOCALHOST_BASE_URL. For tests that need a secure context
(service workers, WebCrypto) — those require a localhost or HTTPS origin.
Requires an apt-based base image (the default images qualify).
"""
localhostProxy: Boolean! = false,
"""
Extra arguments passed to every `playwright test` invocation
(e.g. ["--project", "chromium"]).
"""
args: [String!]! = [],
"""
Playwright reporters every run uses, in place of the config's: built-in
names, paths relative to the project, or installed packages (e.g.
["list", "junit"]). The telemetry reporter is always added. The flag
can't carry reporter options; built-in reporters read theirs from
environment variables such as PLAYWRIGHT_JUNIT_OUTPUT_FILE (see
`environment`). When none of them prints to the terminal, Playwright adds
the dot reporter.
"""
reporters: [String!]! = ["list"],
"""
Number of parallel test shards each project's tests run in.
"""
shards: Int! = 1,
"""
Shell commands run in the project directory after dependencies are
installed and the full source is in place, before the tests (e.g.
["pnpm --filter @my/lib build"] to build workspace packages the tests
import).
"""
setup: [String!]! = [],
) {
self.baseCtr = baseCtr
self.service = service
self.serviceHostname = serviceHostname
self.baseImageAddress = baseImageAddress
self.packageManager = packageManager
self.installFlags = installFlags
self.environment = environment
self.localhostProxy = localhostProxy
self.args = args
self.reporters = reporters
self.shards = shards
self.setup = setup
self
}
"""
Playwright projects at or below the working directory, keyed by config directory.
"""
projects(ws: Workspace!): PlaywrightProjects! {
# One findRoots walk answers this, so listing never runs a container. It
# returns every config directory at or below the cwd, plus the nearest
# enclosing one when the cwd is inside a project; the excludes prune
# node_modules and hidden directories from the walk itself.
let found = ws
.findRoots(markers: configNames, exclude: ["**/node_modules", "**/.*"])
.map { p => rootPath(ws.cwd, p) }
.uniq
PlaywrightProjects(paths: sorted(found), tool: self)
}
"""
The Playwright project containing a workspace path.
"""
project(
ws: Workspace!,
"""
A directory at or below the project's playwright.config, relative to the
working directory (absolute paths resolve from the workspace root).
"""
path: String!,
): PlaywrightProject! {
let from = wsPath(rootPath(ws.cwd, path))
# The nearest config at or above the path wins, whatever its extension.
let empty: [String!]! = []
let dirs = configNames.reduce(empty) { acc, name =>
let found = ws.findUp(name, from: from)
if (found == null) { acc } else { acc + [dirOf(found)] }
}
let nearest = dirs.reduce("") { best, dir =>
if (best == "" or depth(dir) > depth(best)) { dir } else { best }
}
if (nearest == "") {
raise "no playwright.config.* found at or above " + path
}
projectAt(nearest)
}
let projectAt(path: String!): PlaywrightProject! {
PlaywrightProject(path: path, tool: self)
}
}
"""
Playwright projects in a workspace, keyed by config directory.
"""
type PlaywrightProjects @collection {
"""
Directories holding a playwright.config, relative to the workspace root.
"""
paths: [String!]! @keys
"""
Settings every project runs with.
"""
let tool: Playwright!
"""
The Playwright project whose config is in path.
"""
project(path: String!): PlaywrightProject! @get {
tool.projectAt(path)
}
"""
Run Playwright tests in the selected projects.
"""
test(ws: Workspace!): Void {
# A selection over a list evaluates its elements concurrently, so every
# project (and each project's shards) runs in parallel.
let failures = paths.map { p => project(p) }.{{path, failure: testFailure(ws)}}
.filter { result => result.failure != null }
if (failures.length > 0) {
raise "Playwright failed:\n" + failures.map { result =>
"- " + result.path + ": " + (result.failure ?? "")
}.join("\n")
}
null
}
}
"""
A Playwright project: a directory holding a playwright.config.
"""
type PlaywrightProject {
"""
Directory holding the playwright.config, relative to the workspace root.
"""
path: String!
"""
Settings this project runs with.
"""
let tool: Playwright!
# The parent of a workspace path, or null at the root.
let parentOf(dir: String!): String {
if (dir == ".") { null } else { dirOf("/" + dir) }
}
# The directory of the nearest file named name at or above from, or null.
let nearest(ws: Workspace!, name: String!, from: String!): String {
let found = ws.findUp(name, from: wsPath(from))
if (found == null) { null } else { dirOf(found) }
}
# A workspace file's text, or "" when it can't be read.
let text(ws: Workspace!, file: String!): String! {
{ ws.file(wsPath(file)).contents } rescue {
err: Error => ""
}
}
# The nearest directory at or above from whose package.json matches
# pattern, or null.
let packageJsonMatching(ws: Workspace!, from: String!, pattern: String!): String {
let dir = nearest(ws, "package.json", from)
if (dir == null) {
return null
}
let file = if (dir == ".") { "package.json" } else { dir + "/package.json" }
if (text(ws, file).containsMatch(Regexp(pattern))) {
return dir
}
let up = parentOf(dir)
if (up == null) { null } else { packageJsonMatching(ws, up, pattern) }
}
# The deeper of two optional workspace paths on one ancestor chain.
let deeper(a: String, b: String): String {
if (a == null) { b } else if (b == null) { a } else if (depth(a) >= depth(b)) { a } else { b }
}
"""
Where dependencies are installed, relative to the workspace root: the
nearest workspace root at or above the project (pnpm-workspace.yaml, or a
package.json with "workspaces"), else the nearest lockfile, else the
nearest package.json. Null when there is none: the install is skipped and
npx fetches Playwright on demand.
"""
installDir(ws: Workspace!): String {
let workspaceRoot = deeper(
nearest(ws, "pnpm-workspace.yaml", path),
packageJsonMatching(ws, path, `"workspaces"\s*:`),
)
if (workspaceRoot != null) {
return workspaceRoot
}
let locks = lockfiles.map { name => nearest(ws, name, path) ?? "" }.filter { dir => dir != "" }
let lockDir = locks.reduce(locks[0] ?? "") { acc, dir => if (depth(dir) > depth(acc)) { dir } else { acc } }
if (lockDir != "") { lockDir } else { nearest(ws, "package.json", path) }
}
# The lockfiles an install root may hold, most specific first.
let lockfiles: [String!]! {
["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "package-lock.json", "npm-shrinkwrap.json"]
}
# Whether a file exists directly in a workspace directory.
let hasFile(ws: Workspace!, dir: String!, name: String!): Boolean! {
nearest(ws, name, dir) == dir
}
"""
The package manager dependencies are installed with: the packageManager
setting, else the install root's package.json "packageManager" field, else
its lockfile (npm when there is none).
"""
packageManager(ws: Workspace!): String! {
if (tool.packageManager != "") {
return tool.packageManager
}
let root = installDir(ws)
if (root == null) {
return "npm"
}
let declared = text(ws, if (root == ".") { "package.json" } else { root + "/package.json" })
.match(`"packageManager"\s*:\s*"(npm|pnpm|yarn|bun)@`)
if (declared != null) {
return declared.captures[0] ?? "npm"
}
if (hasFile(ws, root, "pnpm-lock.yaml") or hasFile(ws, root, "pnpm-workspace.yaml")) {
"pnpm"
} else if (hasFile(ws, root, "yarn.lock")) {
"yarn"
} else if (hasFile(ws, root, "bun.lock") or hasFile(ws, root, "bun.lockb")) {
"bun"
} else {
"npm"
}
}
# The @playwright/test version the install root's lockfile resolves, or
# null. With several versions in a pnpm lockfile, the declaring package's
# importer entry wins.
let lockedVersion(ws: Workspace!, root: String!, declaring: String!): String {
let file(name: String!): String! {
text(ws, if (root == ".") { name } else { root + "/" + name })
}
let version = `([0-9]+\.[0-9]+\.[0-9]+[-+.0-9A-Za-z]*)`
let pnpm = file("pnpm-lock.yaml")
if (pnpm != "") {
let versions = pnpm.matchAll(Regexp(`@playwright/test@` + version)).map { m => m.captures[0] ?? "" }.uniq
if (versions.length == 1) {
return versions[0]
}
let importer = if (declaring == root) { "." } else { relPath(root, declaring) }
let block = pnpm.split("\n " + importer + ":\n")[1] ?? pnpm.split("\n '" + importer + "':\n")[1] ?? ""
let entry = block.match(Regexp(`'@playwright/test':\s*\n\s+specifier:[^\n]*\n\s+version:\s*` + version))
if (entry != null) {
return entry.captures[0]
}
return versions[0]
}
let yarn = file("yarn.lock").match(Regexp(`@playwright/test@[^\n]*:\n\s+version:?\s+"?` + version))
if (yarn != null) {
return yarn.captures[0]
}
let bun = file("bun.lock").match(Regexp(`"@playwright/test@` + version))
if (bun != null) {
return bun.captures[0]
}
let npm = file("package-lock.json").match(
Regexp(`"(?:[^"]*/)?node_modules/@playwright/test"\s*:\s*\{[^}]*?"version"\s*:\s*"` + version),
)
if (npm != null) {
return npm.captures[0]
}
null
}
# The version a pnpm catalog gives @playwright/test ("" for the default
# catalog), or null.
let catalogVersion(ws: Workspace!, root: String!, catalog: String!): String {
let yaml = text(ws, if (root == ".") { "pnpm-workspace.yaml" } else { root + "/pnpm-workspace.yaml" })
let section = if (catalog == "") {
("\n" + yaml).split("\ncatalog:\n")[1] ?? ""
} else {
let named = ("\n" + yaml).split("\ncatalogs:\n")[1] ?? ""
named.split("\n " + catalog + ":\n")[1] ?? (
if (named.hasPrefix(" " + catalog + ":\n")) {
named.trimPrefix(" " + catalog + ":\n")
} else { "" }
)
}
# The section ends at the first line indented less than its entries.
let indent = if (catalog == "") { " " } else { " " }
let lines = section.split("\n").takeWhile { line => line.hasPrefix(indent) or line.trimSpace == "" or line.trimSpace.hasPrefix("#") }
let entry = lines.join("\n").match(`['"]?@playwright/test['"]?\s*:\s*['"]?([^'"\s#]+)`)
if (entry == null) { null } else { entry.captures[0] }
}
"""
The @playwright/test version the project resolves: from the install root's
lockfile (pnpm-lock.yaml, yarn.lock, bun.lock or package-lock.json), else
the nearest package.json that declares it (resolving pnpm catalog:
specifiers from pnpm-workspace.yaml). Null when none of these gives an
exact version.