1
0
forked from rosa/hakurei

18 Commits

Author SHA1 Message Date
Kat
f4affbea5a TODO: actually write tests lol 2026-03-31 11:39:56 +11:00
Kat
2b06c9328c cmd/pkgserver: add noscript warning to test web UI 2026-03-31 11:39:56 +11:00
Kat
abd41ce443 cmd/pkgserver: relocate JS tests 2026-03-31 11:39:56 +11:00
Kat
4329904461 cmd/pkgserver: aria-describe test node summary with state
The summary marker does not appear in the AOM, so setting its alt text
is fruitless.
2026-03-31 11:39:56 +11:00
Kat
361152c15b cmd/pkgserver: provide role descriptions for test nodes in web UI 2026-03-31 11:39:56 +11:00
Kat
c15da27ae7 cmd/pkgserver: fix dark mode in test web UI 2026-03-31 11:39:56 +11:00
Kat
dcf90cd22e cmd/pkgserver: serialize JS enums as ints instead of strings 2026-03-31 11:39:56 +11:00
Kat
e00c36422b cmd/pkgserver: set exit code when running JS tests from CLI 2026-03-31 11:39:56 +11:00
Kat
98de9006b6 cmd/pkgserver: expose verbose StreamReporter flag via CLI 2026-03-31 11:39:56 +11:00
Kat
6f212d807f cmd/pkgserver: implement skipping JS tests from the DSL 2026-03-31 11:39:56 +11:00
Kat
c0493a74c4 cmd/pkgserver: allow non-global JS test suites 2026-03-31 11:39:56 +11:00
Kat
8a1ac6f57c cmd/pkgserver: serialize raw log list for go test consumption 2026-03-31 11:39:56 +11:00
Kat
cb76a8ae8a cmd/pkgserver: add JSON reporter to facilitate go test integration 2026-03-31 11:39:56 +11:00
Kat
794fa4ae72 cmd/pkgserver: fix multi-line JS test output display 2026-03-31 11:39:56 +11:00
Kat
504f9fc614 cmd/pkgserver: implement JS test DSL and runner 2026-03-31 11:39:55 +11:00
Kat
d7fd1fe24d cmd/pkgserver: move StreamReporter display() to Reporter interface 2026-03-31 11:39:55 +11:00
Kat
f1a9db3d2c cmd/pkgserver: add DOM reporter for JS tests 2026-03-31 11:39:55 +11:00
Kat
787d1b62f3 cmd/pkgserver: add basic CLI reporter for testing JS 2026-03-31 11:39:55 +11:00
11 changed files with 129 additions and 65 deletions

5
.gitignore vendored
View File

@@ -31,7 +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/static /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

View File

@@ -82,7 +82,7 @@ func main() {
}() }()
var mux http.ServeMux var mux http.ServeMux
uiRoutes(&mux) uiRoutes(&mux)
testUIRoutes(&mux) testUiRoutes(&mux)
index.registerAPI(&mux) index.registerAPI(&mux)
server := http.Server{ server := http.Server{
Addr: flagAddr, Addr: flagAddr,

View File

@@ -4,28 +4,94 @@ package main
import ( import (
"embed" "embed"
"io/fs"
"net/http" "net/http"
"path"
"strings"
) )
//go:generate mkdir ui_test/ui //go:generate tsc -p ui_test
//go:generate sh -c "cp ui/static/*.ts ui_test/ui/" //go:generate sass ui_test/lib/ui.scss ui_test/lib/ui.css
//go:generate tsc --outDir ui_test/static -p ui_test //go:embed ui_test/*
//go:generate rm -r ui_test/ui/ var test_content embed.FS
//go:generate sass ui_test/lib/ui.scss ui_test/static/style.css
//go:generate cp ui_test/lib/ui.html ui_test/static/index.html
//go:generate sh -c "cd ui_test/lib && cp *.svg ../static/"
//go:embed ui_test/static
var _staticTest embed.FS
var staticTest = func() fs.FS { func serveTestWebUI(w http.ResponseWriter, r *http.Request) {
if f, err := fs.Sub(_staticTest, "ui_test/static"); err != nil { w.Header().Set("X-Content-Type-Options", "nosniff")
panic(err) w.Header().Set("X-XSS-Protection", "1")
} else { w.Header().Set("X-Frame-Options", "DENY")
return f
}
}()
func testUIRoutes(mux *http.ServeMux) { http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.html")
mux.Handle("GET /test/", http.StripPrefix("/test", http.FileServer(http.FS(staticTest)))) }
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)
} }

View File

@@ -4,4 +4,4 @@ package main
import "net/http" import "net/http"
func testUIRoutes(mux *http.ServeMux) {} func testUiRoutes(mux *http.ServeMux) {}

View File

@@ -1,2 +1,2 @@
// Import all test files to register their test suites. // Import all test files to execute their suite registrations.
import "./sample_tests.js"; import "./test_tests.js";

View File

@@ -5,7 +5,7 @@
// development. // development.
import "../all_tests.js"; import "../all_tests.js";
import { StreamReporter, GLOBAL_REGISTRAR } from "./test.js"; import { StreamReporter, TESTS } from "./test.js";
// TypeScript doesn't like process and Deno as their type definitions aren't // 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 // installed, but doesn't seem to complain if they're accessed through
@@ -44,5 +44,5 @@ if (args.length === 1) {
} }
let reporter = new StreamReporter({ writeln: console.log }, verbose); let reporter = new StreamReporter({ writeln: console.log }, verbose);
GLOBAL_REGISTRAR.run(reporter); TESTS.run(reporter);
exit(reporter.succeeded() ? 0 : 1); exit(reporter.succeeded() ? 0 : 1);

View File

@@ -1,3 +1,3 @@
import "../all_tests.js"; import "../all_tests.js";
import { GoTestReporter, GLOBAL_REGISTRAR } from "./test.js"; import { GoTestReporter, TESTS } from "./test.js";
GLOBAL_REGISTRAR.run(new GoTestReporter()); TESTS.run(new GoTestReporter());

View File

@@ -2,8 +2,8 @@
// DSL // DSL
type TestTree = TestGroup | Test; type TestTree = TestGroup | Test;
type TestGroup = { name: string; children: TestTree[] }; type TestGroup = { name: string, children: TestTree[] };
type Test = { name: string; test: (t: TestController) => void }; type Test = { name: string, test: (t: TestController) => void };
export class TestRegistrar { export class TestRegistrar {
#suites: TestGroup[]; #suites: TestGroup[];
@@ -13,7 +13,7 @@ export class TestRegistrar {
} }
suite(name: string, children: TestTree[]) { suite(name: string, children: TestTree[]) {
checkDuplicates(name, children); checkDuplicates(name, children)
this.#suites.push({ name, children }); this.#suites.push({ name, children });
} }
@@ -26,19 +26,18 @@ export class TestRegistrar {
} }
} }
export let GLOBAL_REGISTRAR = new TestRegistrar(); export let TESTS = new TestRegistrar();
// Register a suite in the global registrar. // Register a suite in the global registrar.
export function suite(name: string, children: TestTree[]) { export function suite(name: string, children: TestTree[]) {
GLOBAL_REGISTRAR.suite(name, children); TESTS.suite(name, children);
} }
export function group(name: string, children: TestTree[]): TestTree { export function context(name: string, children: TestTree[]): TestTree {
checkDuplicates(name, children); checkDuplicates(name, children);
return { name, children }; return { name, children };
} }
export const context = group; export const group = context;
export const describe = group;
export function test(name: string, test: (t: TestController) => void): TestTree { export function test(name: string, test: (t: TestController) => void): TestTree {
return { name, test }; return { name, test };
@@ -149,7 +148,7 @@ function extractExceptionString(e: any): string {
// Reporting // Reporting
export interface Reporter { export interface Reporter {
register(suites: TestGroup[]): void; register(suites: TestGroup[]): void
update(path: string[], result: TestResult): void; update(path: string[], result: TestResult): void;
finalize(): void; finalize(): void;
} }
@@ -230,8 +229,8 @@ export class StreamReporter implements Reporter {
this.#displaySection("skips", this.#skips); this.#displaySection("skips", this.#skips);
this.stream.writeln(""); this.stream.writeln("");
this.stream.writeln( this.stream.writeln(
`${this.#successes.length} succeeded, ${this.#failures.length} failed` + `${this.#successes.length} succeeded, ${this.#failures.length} failed`
(this.#skips.length ? `, ${this.#skips.length} skipped` : ""), + (this.#skips.length ? `, ${this.#skips.length} skipped` : "")
); );
} }
@@ -294,7 +293,7 @@ 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");
if (result.state === "skip") { if (result.state === "skip") {
assertGetElementById("skip-counter-text").hidden = false; assertGetElementById("skip-counter-text").classList.remove("hidden");
} }
const counter = assertGetElementById(`${result.state}-counter`); const counter = assertGetElementById(`${result.state}-counter`);
counter.innerText = (Number(counter.innerText) + 1).toString(); counter.innerText = (Number(counter.innerText) + 1).toString();
@@ -332,8 +331,6 @@ export class DOMReporter implements Reporter {
child.classList.add("failure"); child.classList.add("failure");
child.classList.remove("skip"); child.classList.remove("skip");
child.classList.remove("success"); child.classList.remove("success");
// The summary marker does not appear in the AOM, so setting its
// alt text is fruitless; label the summary itself instead.
summary.setAttribute("aria-labelledby", "failure-description"); summary.setAttribute("aria-labelledby", "failure-description");
break; break;
case "skip": case "skip":
@@ -370,23 +367,23 @@ export class DOMReporter implements Reporter {
interface GoNode { interface GoNode {
name: string; name: string;
subtests?: GoNode[]; subtests: GoNode[] | null;
} }
// Used to display results via `go test`, via some glue code from the Go side. // Used to display results via `go test`, via some glue code from the Go side.
export class GoTestReporter implements Reporter { export class GoTestReporter implements Reporter {
register(suites: TestGroup[]) {
console.log(JSON.stringify(suites.map(GoTestReporter.serialize)));
}
// Convert a test tree into the one expected by the Go code. // Convert a test tree into the one expected by the Go code.
static serialize(node: TestTree): GoNode { static serialize(node: TestTree): GoNode {
return { return {
name: node.name, name: node.name,
subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : undefined, subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : null,
}; };
} }
register(suites: TestGroup[]) {
console.log(JSON.stringify(suites.map(GoTestReporter.serialize)));
}
update(path: string[], result: TestResult) { update(path: string[], result: TestResult) {
let state: number; let state: number;
switch (result.state) { switch (result.state) {
@@ -394,7 +391,7 @@ export class GoTestReporter implements Reporter {
case "failure": state = 1; break; case "failure": state = 1; break;
case "skip": state = 2; break; case "skip": state = 2; break;
} }
console.log(JSON.stringify({ path, state, logs: result.logs })); console.log(JSON.stringify({ path: path, state, logs: result.logs }));
} }
finalize() { finalize() {

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/test/style.css"> <link rel="stylesheet" href="/testui/style.css">
<title>PkgServer Tests</title> <title>PkgServer Tests</title>
</head> </head>
<body> <body>
@@ -19,7 +19,7 @@
<main> <main>
<p id="counters"> <p id="counters">
<span id="success-counter">0</span> succeeded, <span id="failure-counter">0</span> <span id="success-counter">0</span> succeeded, <span id="failure-counter">0</span>
failed<span id="skip-counter-text" hidden>, <span id="skip-counter">0</span> skipped</span>. failed<span id="skip-counter-text" class="hidden">, <span id="skip-counter">0</span> skipped</span>.
</p> </p>
<p hidden id="success-description">Successful test</p> <p hidden id="success-description">Successful test</p>
@@ -31,8 +31,8 @@
<script type="module"> <script type="module">
import "/test/all_tests.js"; import "/test/all_tests.js";
import { DOMReporter, GLOBAL_REGISTRAR } from "/test/lib/test.js"; import { DOMReporter, TESTS } from "/test/lib/test.js";
GLOBAL_REGISTRAR.run(new DOMReporter()); TESTS.run(new DOMReporter());
</script> </script>
</main> </main>
</body> </body>

View File

@@ -1,8 +1,3 @@
/*
* If updating the theme colors, also update them in success-closed.svg and
* success-open.svg!
*/
:root { :root {
--bg: #d3d3d3; --bg: #d3d3d3;
--fg: black; --fg: black;
@@ -54,24 +49,24 @@ details.test-node {
* [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon * [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon
*/ */
color: var(--fg); color: var(--fg);
content: url("/test/success-closed.svg") / "success"; content: url("/testui/success-closed.svg") / "success";
} }
&.success[open] > summary::marker { &.success[open] > summary::marker {
content: url("/test/success-open.svg") / "success"; content: url("/testui/success-open.svg") / "success";
} }
&.failure > summary::marker { &.failure > summary::marker {
color: red; color: red;
content: url("/test/failure-closed.svg") / "failure"; content: url("/testui/failure-closed.svg") / "failure";
} }
&.failure[open] > summary::marker { &.failure[open] > summary::marker {
content: url("/test/failure-open.svg") / "failure"; content: url("/testui/failure-open.svg") / "failure";
} }
&.skip > summary::marker { &.skip > summary::marker {
color: blue; color: blue;
content: url("/test/skip-closed.svg") / "skip"; content: url("/testui/skip-closed.svg") / "skip";
} }
&.skip[open] > summary::marker { &.skip[open] > summary::marker {
content: url("/test/skip-open.svg") / "skip"; content: url("/testui/skip-open.svg") / "skip";
} }
} }
@@ -83,6 +78,10 @@ p.test-desc {
} }
} }
.hidden {
display: none;
}
.italic { .italic {
font-style: italic; font-style: italic;
} }

View File

@@ -1,4 +1,3 @@
import "./ui/index.js";
import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./lib/test.js"; import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./lib/test.js";
suite("dog", [ suite("dog", [