forked from rosa/hakurei
Compare commits
10 Commits
5b7bfeaee7
...
5ae3083690
| Author | SHA1 | Date | |
|---|---|---|---|
|
5ae3083690
|
|||
|
29f971b891
|
|||
|
7f932abfb9
|
|||
|
fac9ed4079
|
|||
|
29705065b8
|
|||
|
85b3d0e5cd
|
|||
|
a9816c7c93
|
|||
|
7be50fb888
|
|||
|
ab7eef1f5e
|
|||
|
9b0f8d123f
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,6 +31,10 @@ go.work.sum
|
|||||||
/cmd/pkgserver/ui/static/*.js
|
/cmd/pkgserver/ui/static/*.js
|
||||||
/cmd/pkgserver/ui/static/*.css*
|
/cmd/pkgserver/ui/static/*.css*
|
||||||
/cmd/pkgserver/ui/static/*.css.map
|
/cmd/pkgserver/ui/static/*.css.map
|
||||||
|
/cmd/pkgserver/ui_test/*.js
|
||||||
|
/cmd/pkgserver/ui_test/lib/*.js
|
||||||
|
/cmd/pkgserver/ui_test/lib/*.css*
|
||||||
|
/cmd/pkgserver/ui_test/lib/*.css.map
|
||||||
/internal/pkg/testdata/testtool
|
/internal/pkg/testdata/testtool
|
||||||
/internal/rosa/hakurei_current.tar.gz
|
/internal/rosa/hakurei_current.tar.gz
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
var mux http.ServeMux
|
var mux http.ServeMux
|
||||||
uiRoutes(&mux)
|
uiRoutes(&mux)
|
||||||
|
testUiRoutes(&mux)
|
||||||
index.registerAPI(&mux)
|
index.registerAPI(&mux)
|
||||||
server := http.Server{
|
server := http.Server{
|
||||||
Addr: flagAddr,
|
Addr: flagAddr,
|
||||||
|
|||||||
97
cmd/pkgserver/test_ui.go
Normal file
97
cmd/pkgserver/test_ui.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//go:build frontend && frontend_test
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate tsc -p ui_test
|
||||||
|
//go:generate sass ui_test/lib/ui.scss ui_test/lib/ui.css
|
||||||
|
//go:embed ui_test/*
|
||||||
|
var test_content embed.FS
|
||||||
|
|
||||||
|
func serveTestWebUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-XSS-Protection", "1")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTestWebUIStaticContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/testui/style.css":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.css")
|
||||||
|
case "/testui/skip-closed.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-closed.svg")
|
||||||
|
case "/testui/skip-open.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-open.svg")
|
||||||
|
case "/testui/success-closed.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/success-closed.svg")
|
||||||
|
case "/testui/success-open.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/success-open.svg")
|
||||||
|
case "/testui/failure-closed.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-closed.svg")
|
||||||
|
case "/testui/failure-open.svg":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-open.svg")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTestLibrary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/test/lib/test.js":
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/lib/test.js")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/test/" {
|
||||||
|
http.Redirect(w, r, "/test.html", http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testPath := strings.TrimPrefix(r.URL.Path, "/test/")
|
||||||
|
|
||||||
|
if path.Ext(testPath) != ".js" {
|
||||||
|
http.Error(w, "403 forbidden", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Header().Set("Expires", "0")
|
||||||
|
|
||||||
|
http.ServeFileFS(w, r, test_content, "ui_test/"+testPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// The base path should not redirect to the root.
|
||||||
|
if r.URL.Path == "/ui/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path.Ext(r.URL.Path) != ".js" {
|
||||||
|
http.Error(w, "403 forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Header().Set("Expires", "0")
|
||||||
|
|
||||||
|
http.Redirect(w, r, strings.TrimPrefix(r.URL.Path, "/ui"), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUiRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /test.html", serveTestWebUI)
|
||||||
|
mux.HandleFunc("GET /testui/", serveTestWebUIStaticContent)
|
||||||
|
mux.HandleFunc("GET /test/lib", serveTestLibrary)
|
||||||
|
mux.HandleFunc("GET /test/", serveTests)
|
||||||
|
mux.HandleFunc("GET /ui/", redirectUI)
|
||||||
|
}
|
||||||
7
cmd/pkgserver/test_ui_stub.go
Normal file
7
cmd/pkgserver/test_ui_stub.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !(frontend && frontend_test)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func testUiRoutes(mux *http.ServeMux) {}
|
||||||
@@ -25,26 +25,14 @@ func serveStaticContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
|
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
|
||||||
case "/static/index.js":
|
case "/static/index.js":
|
||||||
http.ServeFileFS(w, r, content, "ui/static/index.js")
|
http.ServeFileFS(w, r, content, "ui/static/index.js")
|
||||||
case "/static/test.js":
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/test.js")
|
|
||||||
case "/static/test.css":
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/test.css")
|
|
||||||
case "/static/all_tests.js":
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/all_tests.js")
|
|
||||||
case "/static/test_tests.js":
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/test_tests.js")
|
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func serveTester(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFileFS(w, r, content, "ui/test.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
func uiRoutes(mux *http.ServeMux) {
|
func uiRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /{$}", serveWebUI)
|
mux.HandleFunc("GET /{$}", serveWebUI)
|
||||||
mux.HandleFunc("GET /favicon.ico", serveStaticContent)
|
mux.HandleFunc("GET /favicon.ico", serveStaticContent)
|
||||||
mux.HandleFunc("GET /static/", serveStaticContent)
|
mux.HandleFunc("GET /static/", serveStaticContent)
|
||||||
mux.HandleFunc("GET /test.html", serveTester)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Many editors have terminal emulators built in, so running tests with NodeJS
|
|
||||||
// provides faster iteration, especially for those acclimated to test-driven
|
|
||||||
// development.
|
|
||||||
import "./all_tests.js";
|
|
||||||
import { StreamReporter, TESTS } from "./test.js";
|
|
||||||
TESTS.run(new StreamReporter({ writeln: console.log }));
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
h1, p, summary {
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
details.test-node {
|
|
||||||
margin-left: 1rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-left: 2px dashed black;
|
|
||||||
> summary {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
&.failure > summary::marker {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.test-desc {
|
|
||||||
margin: 0 0 0 1rem;
|
|
||||||
padding: 2px 0;
|
|
||||||
> pre {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="static/style.css">
|
|
||||||
<link rel="stylesheet" href="static/test.css">
|
|
||||||
<title>PkgServer Tests</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>PkgServer Tests</h1>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<p id="counters">
|
|
||||||
<span id="successes">0</span> succeeded, <span id="failures">0</span> failed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="root">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import "./static/all_tests.js";
|
|
||||||
import { DOMReporter, TESTS } from "./static/test.js";
|
|
||||||
TESTS.run(new DOMReporter());
|
|
||||||
</script>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -4,6 +4,6 @@ package main
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && sass ui/static/test.scss ui/static/test.css && tsc -p ui/static"
|
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc -p ui/static"
|
||||||
//go:embed ui/*
|
//go:embed ui/*
|
||||||
var content embed.FS
|
var content embed.FS
|
||||||
|
|||||||
48
cmd/pkgserver/ui_test/lib/cli.ts
Normal file
48
cmd/pkgserver/ui_test/lib/cli.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Many editors have terminal emulators built in, so running tests with NodeJS
|
||||||
|
// provides faster iteration, especially for those acclimated to test-driven
|
||||||
|
// development.
|
||||||
|
|
||||||
|
import "../all_tests.js";
|
||||||
|
import { StreamReporter, TESTS } from "./test.js";
|
||||||
|
|
||||||
|
// TypeScript doesn't like process and Deno as their type definitions aren't
|
||||||
|
// installed, but doesn't seem to complain if they're accessed through
|
||||||
|
// globalThis.
|
||||||
|
const process: any = (globalThis as any).process;
|
||||||
|
const Deno: any = (globalThis as any).Deno;
|
||||||
|
|
||||||
|
function getArgs(): string[] {
|
||||||
|
if (process) {
|
||||||
|
const [runtime, program, ...args] = process.argv;
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
if (Deno) return Deno.args;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exit(code?: number): never {
|
||||||
|
if (Deno) Deno.exit(code);
|
||||||
|
if (process) process.exit(code);
|
||||||
|
throw `exited with code ${code ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = getArgs();
|
||||||
|
let verbose = false;
|
||||||
|
if (args.length > 1) {
|
||||||
|
console.error("Too many arguments");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (args.length === 1) {
|
||||||
|
if (args[0] === "-v" || args[0] === "--verbose" || args[0] === "-verbose") {
|
||||||
|
verbose = true;
|
||||||
|
} else if (args[0] !== "--") {
|
||||||
|
console.error(`Unknown argument '${args[0]}'`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let reporter = new StreamReporter({ writeln: console.log }, verbose);
|
||||||
|
TESTS.run(reporter);
|
||||||
|
exit(reporter.succeeded() ? 0 : 1);
|
||||||
13
cmd/pkgserver/ui_test/lib/failure-closed.svg
Normal file
13
cmd/pkgserver/ui_test/lib/failure-closed.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<!-- This triangle should match success-closed.svg, fill and stroke color notwithstanding. -->
|
||||||
|
<polygon points="0,0 100,50 0,100" fill="red" stroke="red" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
<!--
|
||||||
|
! y-coordinates go before x-coordinates here to highlight the difference
|
||||||
|
! (or, lack thereof) between these numbers and the ones in failure-open.svg;
|
||||||
|
! try a textual diff. Make sure to keep the numbers in sync!
|
||||||
|
-->
|
||||||
|
<line y1="30" x1="10" y2="70" x2="50" stroke="white" stroke-width="16"/>
|
||||||
|
<line y1="30" x1="50" y2="70" x2="10" stroke="white" stroke-width="16"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 788 B |
35
cmd/pkgserver/ui_test/lib/failure-open.svg
Normal file
35
cmd/pkgserver/ui_test/lib/failure-open.svg
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!--
|
||||||
|
! This view box is a bit weird: the strokes assume they're working in a view
|
||||||
|
! box that spans from the (0,0) to (100,100), and indeed that is convenient
|
||||||
|
! conceptualizing the strokes, but the stroke itself has a considerable width
|
||||||
|
! that gets clipped by restrictive view box dimensions. Hence, the view is
|
||||||
|
! shifted from (0,0)–(100,100) to (-20,-20)–(120,120), to make room for the
|
||||||
|
! clipped stroke, while leaving behind an illusion of working in a view box
|
||||||
|
! spanning from (0,0) to (100,100).
|
||||||
|
!
|
||||||
|
! However, the resulting SVG is too close to the summary text, and CSS
|
||||||
|
! properties to add padding do not seem to work with `content:` (likely because
|
||||||
|
! they're anonymous replaced elements); thus, the width of the view is
|
||||||
|
! increased considerably to provide padding in the SVG itself, while leaving
|
||||||
|
! the strokes oblivious.
|
||||||
|
!
|
||||||
|
! It gets worse: the summary text isn't vertically aligned with the icon! As
|
||||||
|
! a flexbox cannot be used in a summary to align the marker with the text, the
|
||||||
|
! simplest and most effective solution is to reduce the height of the view box
|
||||||
|
! from 140 to 130, thereby removing some of the bottom padding present.
|
||||||
|
!
|
||||||
|
! All six SVGs use the same view box (and indeed, they refer to this comment)
|
||||||
|
! so that they all appear to be the same size and position relative to each
|
||||||
|
! other on the DOM—indeed, the view box dimensions, alongside the width,
|
||||||
|
! directly control their placement on the DOM.
|
||||||
|
!
|
||||||
|
! TL;DR: CSS is janky, overflow is weird, and SVG is awesome!
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<!-- This triangle should match success-open.svg, fill and stroke color notwithstanding. -->
|
||||||
|
<polygon points="0,0 100,0 50,100" fill="red" stroke="red" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
<!-- See the comment in failure-closed.svg before modifying this. -->
|
||||||
|
<line x1="30" y1="10" x2="70" y2="50" stroke="white" stroke-width="16"/>
|
||||||
|
<line x1="30" y1="50" x2="70" y2="10" stroke="white" stroke-width="16"/>
|
||||||
|
</svg>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import "./all_tests.js";
|
import "../all_tests.js";
|
||||||
import { GoTestReporter, TESTS } from "./test.js";
|
import { GoTestReporter, TESTS } from "./test.js";
|
||||||
TESTS.run(new GoTestReporter());
|
TESTS.run(new GoTestReporter());
|
||||||
21
cmd/pkgserver/ui_test/lib/skip-closed.svg
Normal file
21
cmd/pkgserver/ui_test/lib/skip-closed.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<!-- This triangle should match success-closed.svg, fill and stroke color notwithstanding. -->
|
||||||
|
<polygon points="0,0 100,50 0,100" fill="blue" stroke="blue" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
<!--
|
||||||
|
! This path is extremely similar to the one in skip-open.svg; before
|
||||||
|
! making minor modifications, diff the two to understand how they should
|
||||||
|
! remain in sync.
|
||||||
|
-->
|
||||||
|
<path
|
||||||
|
d="M 50,50
|
||||||
|
A 23,23 270,1,1 30,30
|
||||||
|
l -10,20
|
||||||
|
m 10,-20
|
||||||
|
l -20,-10"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="12"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 812 B |
21
cmd/pkgserver/ui_test/lib/skip-open.svg
Normal file
21
cmd/pkgserver/ui_test/lib/skip-open.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<!-- This triangle should match success-open.svg, fill and stroke color notwithstanding. -->
|
||||||
|
<polygon points="0,0 100,0 50,100" fill="blue" stroke="blue" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
<!--
|
||||||
|
! This path is extremely similar to the one in skip-closed.svg; before
|
||||||
|
! making minor modifications, diff the two to understand how they should
|
||||||
|
! remain in sync.
|
||||||
|
-->
|
||||||
|
<path
|
||||||
|
d="M 50,50
|
||||||
|
A 23,23 270,1,1 70,30
|
||||||
|
l 10,-20
|
||||||
|
m -10,20
|
||||||
|
l -20,-10"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="12"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 812 B |
16
cmd/pkgserver/ui_test/lib/success-closed.svg
Normal file
16
cmd/pkgserver/ui_test/lib/success-closed.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<style>
|
||||||
|
.adaptive-stroke {
|
||||||
|
stroke: black;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.adaptive-stroke {
|
||||||
|
stroke: ghostwhite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- When updating this triangle, also update the other five SVGs. -->
|
||||||
|
<polygon points="0,0 100,50 0,100" fill="none" class="adaptive-stroke" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
16
cmd/pkgserver/ui_test/lib/success-open.svg
Normal file
16
cmd/pkgserver/ui_test/lib/success-open.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- See failure-open.svg for an explanation of the view box dimensions. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" viewBox="-20,-20 160 130">
|
||||||
|
<style>
|
||||||
|
.adaptive-stroke {
|
||||||
|
stroke: black;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.adaptive-stroke {
|
||||||
|
stroke: ghostwhite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- When updating this triangle, also update the other five SVGs. -->
|
||||||
|
<polygon points="0,0 100,0 50,100" fill="none" class="adaptive-stroke" stroke-width="15" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
@@ -53,28 +53,34 @@ function checkDuplicates(parent: string, names: { name: string }[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FailNowSentinel {}
|
export type TestState = "success" | "failure" | "skip";
|
||||||
|
|
||||||
|
class AbortSentinel {}
|
||||||
|
|
||||||
export class TestController {
|
export class TestController {
|
||||||
|
#state: TestState;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
#failed: boolean;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.#state = "success";
|
||||||
this.logs = [];
|
this.logs = [];
|
||||||
this.#failed = false;
|
}
|
||||||
|
|
||||||
|
getState(): TestState {
|
||||||
|
return this.#state;
|
||||||
}
|
}
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
this.#failed = true;
|
this.#state = "failure";
|
||||||
}
|
}
|
||||||
|
|
||||||
failed(): boolean {
|
failed(): boolean {
|
||||||
return this.#failed;
|
return this.#state === "failure";
|
||||||
}
|
}
|
||||||
|
|
||||||
failNow(): never {
|
failNow(): never {
|
||||||
this.fail();
|
this.fail();
|
||||||
throw new FailNowSentinel();
|
throw new AbortSentinel();
|
||||||
}
|
}
|
||||||
|
|
||||||
log(message: string) {
|
log(message: string) {
|
||||||
@@ -90,13 +96,23 @@ export class TestController {
|
|||||||
this.log(message);
|
this.log(message);
|
||||||
this.failNow();
|
this.failNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skip(message?: string): never {
|
||||||
|
if (message != null) this.log(message);
|
||||||
|
if (this.#state !== "failure") this.#state = "skip";
|
||||||
|
throw new AbortSentinel();
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped(): boolean {
|
||||||
|
return this.#state === "skip";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Execution
|
// Execution
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
success: boolean;
|
state: TestState;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +126,11 @@ function runTests(reporter: Reporter, parents: string[], node: TestTree) {
|
|||||||
try {
|
try {
|
||||||
node.test(controller);
|
node.test(controller);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof FailNowSentinel)) {
|
if (!(e instanceof AbortSentinel)) {
|
||||||
controller.error(extractExceptionString(e));
|
controller.error(extractExceptionString(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reporter.update(path, { success: !controller.failed(), logs: controller.logs });
|
reporter.update(path, { state: controller.getState(), logs: controller.logs });
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractExceptionString(e: any): string {
|
function extractExceptionString(e: any): string {
|
||||||
@@ -170,14 +186,20 @@ const SEP = " ❯ ";
|
|||||||
export class StreamReporter implements Reporter {
|
export class StreamReporter implements Reporter {
|
||||||
stream: Stream;
|
stream: Stream;
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
|
#successes: ({ path: string[] } & TestResult)[];
|
||||||
#failures: ({ path: string[] } & TestResult)[];
|
#failures: ({ path: string[] } & TestResult)[];
|
||||||
counts: { successes: number, failures: number };
|
#skips: ({ path: string[] } & TestResult)[];
|
||||||
|
|
||||||
constructor(stream: Stream, verbose: boolean = false) {
|
constructor(stream: Stream, verbose: boolean = false) {
|
||||||
this.stream = stream;
|
this.stream = stream;
|
||||||
this.verbose = verbose;
|
this.verbose = verbose;
|
||||||
|
this.#successes = [];
|
||||||
this.#failures = [];
|
this.#failures = [];
|
||||||
this.counts = { successes: 0, failures: 0 };
|
this.#skips = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
succeeded(): boolean {
|
||||||
|
return this.#successes.length > 0 && this.#failures.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(suites: TestGroup[]) {}
|
register(suites: TestGroup[]) {}
|
||||||
@@ -185,32 +207,53 @@ export class StreamReporter implements Reporter {
|
|||||||
update(path: string[], result: TestResult) {
|
update(path: string[], result: TestResult) {
|
||||||
if (path.length === 0) throw new RangeError("path is empty");
|
if (path.length === 0) throw new RangeError("path is empty");
|
||||||
const pathStr = path.join(SEP);
|
const pathStr = path.join(SEP);
|
||||||
if (result.success) {
|
switch (result.state) {
|
||||||
this.counts.successes++;
|
case "success":
|
||||||
|
this.#successes.push({ path, ...result });
|
||||||
if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
|
if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
|
||||||
} else {
|
break;
|
||||||
this.counts.failures++;
|
case "failure":
|
||||||
this.stream.writeln(`⚠️ ${pathStr}`);
|
|
||||||
this.#failures.push({ path, ...result });
|
this.#failures.push({ path, ...result });
|
||||||
|
this.stream.writeln(`⚠️ ${pathStr}`);
|
||||||
|
break;
|
||||||
|
case "skip":
|
||||||
|
this.#skips.push({ path, ...result });
|
||||||
|
this.stream.writeln(`⏭️ ${pathStr}`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalize() {
|
finalize() {
|
||||||
|
if (this.verbose) this.#displaySection("successes", this.#successes, true);
|
||||||
|
this.#displaySection("failures", this.#failures);
|
||||||
|
this.#displaySection("skips", this.#skips);
|
||||||
|
this.stream.writeln("");
|
||||||
|
this.stream.writeln(
|
||||||
|
`${this.#successes.length} succeeded, ${this.#failures.length} failed`
|
||||||
|
+ (this.#skips.length ? `, ${this.#skips.length} skipped` : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) {
|
||||||
|
if (!data.length) return;
|
||||||
|
|
||||||
// Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }]
|
// Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }]
|
||||||
// into { "a ❯ b": ["c", "d"] }.
|
// into { "a ❯ b": ["c", "d"] }.
|
||||||
let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
|
let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
|
||||||
for (const f of this.#failures) {
|
for (const t of data) {
|
||||||
if (f.path.length === 0) throw new RangeError("path is empty");
|
if (t.path.length === 0) throw new RangeError("path is empty");
|
||||||
const key = f.path.slice(0, -1).join(SEP);
|
const key = t.path.slice(0, -1).join(SEP);
|
||||||
if (!pathMap.has(key)) pathMap.set(key, []);
|
if (!pathMap.has(key)) pathMap.set(key, []);
|
||||||
pathMap.get(key)!.push({ name: f.path.at(-1)!, ...f });
|
pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stream.writeln("");
|
this.stream.writeln("");
|
||||||
this.stream.writeln("FAILURES");
|
this.stream.writeln(name.toUpperCase());
|
||||||
this.stream.writeln("========");
|
this.stream.writeln("=".repeat(name.length));
|
||||||
|
|
||||||
for (const [path, tests] of pathMap) {
|
for (let [path, tests] of pathMap) {
|
||||||
|
if (ignoreEmpty) tests = tests.filter((t) => t.logs.length);
|
||||||
|
if (tests.length === 0) continue;
|
||||||
if (tests.length === 1) {
|
if (tests.length === 1) {
|
||||||
this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
|
this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
|
||||||
} else {
|
} else {
|
||||||
@@ -218,10 +261,6 @@ export class StreamReporter implements Reporter {
|
|||||||
for (const t of tests) this.#writeOutput(t, " - ", true);
|
for (const t of tests) this.#writeOutput(t, " - ", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stream.writeln("");
|
|
||||||
const { successes, failures } = this.counts;
|
|
||||||
this.stream.writeln(`${successes} succeeded, ${failures} failed`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
|
#writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
|
||||||
@@ -253,11 +292,16 @@ export class DOMReporter implements Reporter {
|
|||||||
|
|
||||||
update(path: string[], result: TestResult) {
|
update(path: string[], result: TestResult) {
|
||||||
if (path.length === 0) throw new RangeError("path is empty");
|
if (path.length === 0) throw new RangeError("path is empty");
|
||||||
const counter = assertGetElementById(result.success ? "successes" : "failures");
|
if (result.state === "skip") {
|
||||||
|
assertGetElementById("skip-counter-text").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
const counter = assertGetElementById(`${result.state}-counter`);
|
||||||
counter.innerText = (Number(counter.innerText) + 1).toString();
|
counter.innerText = (Number(counter.innerText) + 1).toString();
|
||||||
|
|
||||||
let parent = assertGetElementById("root");
|
let parent = assertGetElementById("root");
|
||||||
for (const node of path) {
|
for (const node of path) {
|
||||||
let child: HTMLDetailsElement | null = null;
|
let child: HTMLDetailsElement | null = null;
|
||||||
|
let summary: HTMLElement | null = null;
|
||||||
let d: Element;
|
let d: Element;
|
||||||
outer: for (d of parent.children) {
|
outer: for (d of parent.children) {
|
||||||
if (!(d instanceof HTMLDetailsElement)) continue;
|
if (!(d instanceof HTMLDetailsElement)) continue;
|
||||||
@@ -265,23 +309,46 @@ export class DOMReporter implements Reporter {
|
|||||||
if (!(s instanceof HTMLElement)) continue;
|
if (!(s instanceof HTMLElement)) continue;
|
||||||
if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
|
if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
|
||||||
child = d;
|
child = d;
|
||||||
|
summary = s;
|
||||||
break outer;
|
break outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!child) {
|
if (!child) {
|
||||||
child = document.createElement("details");
|
child = document.createElement("details");
|
||||||
child.className = "test-node";
|
child.className = "test-node";
|
||||||
const summary = document.createElement("summary");
|
child.ariaRoleDescription = "test";
|
||||||
|
summary = document.createElement("summary");
|
||||||
summary.appendChild(document.createTextNode(node));
|
summary.appendChild(document.createTextNode(node));
|
||||||
|
summary.ariaRoleDescription = "test name";
|
||||||
child.appendChild(summary);
|
child.appendChild(summary);
|
||||||
parent.appendChild(child);
|
parent.appendChild(child);
|
||||||
}
|
}
|
||||||
if (!result.success) {
|
if (!summary) throw new Error("unreachable as assigned above");
|
||||||
|
|
||||||
|
switch (result.state) {
|
||||||
|
case "failure":
|
||||||
child.open = true;
|
child.open = true;
|
||||||
child.classList.add("failure");
|
child.classList.add("failure");
|
||||||
|
child.classList.remove("skip");
|
||||||
|
child.classList.remove("success");
|
||||||
|
summary.setAttribute("aria-labelledby", "failure-description");
|
||||||
|
break;
|
||||||
|
case "skip":
|
||||||
|
if (child.classList.contains("failure")) break;
|
||||||
|
child.classList.add("skip");
|
||||||
|
child.classList.remove("success");
|
||||||
|
summary.setAttribute("aria-labelledby", "skip-description");
|
||||||
|
break;
|
||||||
|
case "success":
|
||||||
|
if (child.classList.contains("failure") || child.classList.contains("skip")) break;
|
||||||
|
child.classList.add("success");
|
||||||
|
summary.setAttribute("aria-labelledby", "success-description");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
parent = child;
|
parent = child;
|
||||||
}
|
}
|
||||||
|
|
||||||
const p = document.createElement("p");
|
const p = document.createElement("p");
|
||||||
p.classList.add("test-desc");
|
p.classList.add("test-desc");
|
||||||
if (result.logs.length) {
|
if (result.logs.length) {
|
||||||
@@ -318,7 +385,13 @@ export class GoTestReporter implements Reporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(path: string[], result: TestResult) {
|
update(path: string[], result: TestResult) {
|
||||||
console.log(JSON.stringify({ path: path, ...result }));
|
let state: number;
|
||||||
|
switch (result.state) {
|
||||||
|
case "success": state = 0; break;
|
||||||
|
case "failure": state = 1; break;
|
||||||
|
case "skip": state = 2; break;
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ path: path, state, logs: result.logs }));
|
||||||
}
|
}
|
||||||
|
|
||||||
finalize() {
|
finalize() {
|
||||||
39
cmd/pkgserver/ui_test/lib/ui.html
Normal file
39
cmd/pkgserver/ui_test/lib/ui.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="/testui/style.css">
|
||||||
|
<title>PkgServer Tests</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
I hate JavaScript as much as you, but this page runs tests written in
|
||||||
|
JavaScript to test the functionality of code written in JavaScript, so it
|
||||||
|
wouldn't make sense for it to work without JavaScript. <strong>Please turn
|
||||||
|
JavaScript on!</strong>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<h1>PkgServer Tests</h1>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<p id="counters">
|
||||||
|
<span id="success-counter">0</span> succeeded, <span id="failure-counter">0</span>
|
||||||
|
failed<span id="skip-counter-text" class="hidden">, <span id="skip-counter">0</span> skipped</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p hidden id="success-description">Successful test</p>
|
||||||
|
<p hidden id="failure-description">Failed test</p>
|
||||||
|
<p hidden id="skip-description">Partially or fully skipped test</p>
|
||||||
|
|
||||||
|
<div id="root">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import "/test/all_tests.js";
|
||||||
|
import { DOMReporter, TESTS } from "/test/lib/test.js";
|
||||||
|
TESTS.run(new DOMReporter());
|
||||||
|
</script>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
87
cmd/pkgserver/ui_test/lib/ui.scss
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #d3d3d3;
|
||||||
|
--fg: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #2c2c2c;
|
||||||
|
--fg: ghostwhite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, p, summary, noscript {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
noscript {
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.test-node {
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-left: 2px dashed var(--fg);
|
||||||
|
> summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
&.success > summary::marker {
|
||||||
|
/*
|
||||||
|
* WebKit only supports color and font-size properties in ::marker [1],
|
||||||
|
* and its ::-webkit-details-marker only supports hiding the marker
|
||||||
|
* entirely [2], contrary to mdn's example [3]; thus, set a color as
|
||||||
|
* a fallback: while it may not be accessible for colorblind
|
||||||
|
* individuals, it's better than no indication of a test's state for
|
||||||
|
* anyone, as that there's no other way to include an indication in the
|
||||||
|
* marker on WebKit.
|
||||||
|
*
|
||||||
|
* [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::marker#browser_compatibility
|
||||||
|
* [2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#default_style
|
||||||
|
* [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon
|
||||||
|
*/
|
||||||
|
color: var(--fg);
|
||||||
|
content: url("/testui/success-closed.svg") / "success";
|
||||||
|
}
|
||||||
|
&.success[open] > summary::marker {
|
||||||
|
content: url("/testui/success-open.svg") / "success";
|
||||||
|
}
|
||||||
|
&.failure > summary::marker {
|
||||||
|
color: red;
|
||||||
|
content: url("/testui/failure-closed.svg") / "failure";
|
||||||
|
}
|
||||||
|
&.failure[open] > summary::marker {
|
||||||
|
content: url("/testui/failure-open.svg") / "failure";
|
||||||
|
}
|
||||||
|
&.skip > summary::marker {
|
||||||
|
color: blue;
|
||||||
|
content: url("/testui/skip-closed.svg") / "skip";
|
||||||
|
}
|
||||||
|
&.skip[open] > summary::marker {
|
||||||
|
content: url("/testui/skip-open.svg") / "skip";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.test-desc {
|
||||||
|
margin: 0 0 0 1rem;
|
||||||
|
padding: 2px 0;
|
||||||
|
> pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./test.js";
|
import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./lib/test.js";
|
||||||
|
|
||||||
suite("dog", [
|
suite("dog", [
|
||||||
group("tail", [
|
group("tail", [
|
||||||
@@ -37,6 +37,10 @@ suite("cat", [
|
|||||||
test("likes headpats", (t) => {
|
test("likes headpats", (t) => {
|
||||||
t.log("meow");
|
t.log("meow");
|
||||||
}),
|
}),
|
||||||
|
test("owns skipping rope", (t) => {
|
||||||
|
t.skip("this cat is stuck in your machine!");
|
||||||
|
t.log("never logged");
|
||||||
|
}),
|
||||||
test("tester tester", (t) => {
|
test("tester tester", (t) => {
|
||||||
const r = new TestRegistrar();
|
const r = new TestRegistrar();
|
||||||
r.suite("explod", [
|
r.suite("explod", [
|
||||||
@@ -73,7 +77,7 @@ suite("cat", [
|
|||||||
result.path[1] === "with yarn")) {
|
result.path[1] === "with yarn")) {
|
||||||
t.error(`incorrect result path got=${result.path} want=["explod", "with yarn"]`);
|
t.error(`incorrect result path got=${result.path} want=["explod", "with yarn"]`);
|
||||||
}
|
}
|
||||||
if (!result.success) t.error(`expected test to succeed`);
|
if (result.state !== "success") t.error(`expected test to succeed`);
|
||||||
if (!(result.logs.length === 1 && result.logs[0] === "YAY")) {
|
if (!(result.logs.length === 1 && result.logs[0] === "YAY")) {
|
||||||
t.error(`incorrect result logs got=${result.logs} want=["YAY"]`);
|
t.error(`incorrect result logs got=${result.logs} want=["YAY"]`);
|
||||||
}
|
}
|
||||||
6
cmd/pkgserver/ui_test/tsconfig.json
Normal file
6
cmd/pkgserver/ui_test/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2024"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user