forked from rosa/hakurei
Compare commits
10 Commits
0183064f25
...
pkgserver-
| Author | SHA1 | Date | |
|---|---|---|---|
|
ec81401b29
|
|||
|
ac83f3fd65
|
|||
|
244db6a530
|
|||
|
62870d959e
|
|||
|
4d76e42f5f
|
|||
|
cbd2eb0b7d
|
|||
|
d03095b2ce
|
|||
| 36ea4df936 | |||
| 4f73062292 | |||
| da00d149bd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
|||||||
/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
|
||||||
/internal/pkg/testdata/testtool
|
/internal/pkg/testdata/testtool
|
||||||
/internal/rosa/hakurei_current.tar.gz
|
/internal/rosa/hakurei_current.tar.gz
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request)
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
search, err := url.PathUnescape(q.Get("search"))
|
search, err := url.QueryUnescape(q.Get("search"))
|
||||||
if len(search) > 100 || err != nil {
|
if len(search) > 100 || err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
w, "search must be a string between 0 and 100 characters long",
|
w, "search must be a string between 0 and 100 characters long",
|
||||||
@@ -142,7 +142,7 @@ func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeAPIPayload(w, &struct {
|
writeAPIPayload(w, &struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Results []searchResult `json:"results"`
|
Values []searchResult `json:"values"`
|
||||||
}{n, res})
|
}{n, res})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hakurei.app/check"
|
||||||
"hakurei.app/command"
|
"hakurei.app/command"
|
||||||
"hakurei.app/container/check"
|
|
||||||
"hakurei.app/internal/pkg"
|
"hakurei.app/internal/pkg"
|
||||||
"hakurei.app/internal/rosa"
|
"hakurei.app/internal/rosa"
|
||||||
"hakurei.app/message"
|
"hakurei.app/message"
|
||||||
@@ -47,7 +47,7 @@ func main() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err = pkg.Open(ctx, msg, 0, baseDir)
|
cache, err = pkg.Open(ctx, msg, 0, 0, baseDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ type searchCacheEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) {
|
func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) {
|
||||||
entry, ok := index.search[search]
|
query := search
|
||||||
if ok {
|
if desc {
|
||||||
return len(entry.results), entry.results[i:min(i+limit, len(entry.results))], nil
|
query += ";withDesc"
|
||||||
|
}
|
||||||
|
entry, ok := index.search[query]
|
||||||
|
if ok && len(entry.results) > 0 {
|
||||||
|
return len(entry.results), entry.results[min(i, len(entry.results)-1):min(i+limit, len(entry.results))], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
regex, err := regexp.Compile(search)
|
regex, err := regexp.Compile(search)
|
||||||
@@ -59,7 +63,7 @@ func (index *packageIndex) performSearchQuery(limit int, i int, search string, d
|
|||||||
results: res,
|
results: res,
|
||||||
expiry: expiry,
|
expiry: expiry,
|
||||||
}
|
}
|
||||||
index.search[search] = entry
|
index.search[query] = entry
|
||||||
|
|
||||||
return len(res), res[i:min(i+limit, len(entry.results))], nil
|
return len(res), res[i:min(i+limit, len(entry.results))], nil
|
||||||
}
|
}
|
||||||
|
|||||||
35
cmd/pkgserver/test_ui.go
Normal file
35
cmd/pkgserver/test_ui.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build frontend && frontend_test
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always remove ui_test/ui; if the previous tsc run failed, the rm never
|
||||||
|
// executes.
|
||||||
|
|
||||||
|
//go:generate sh -c "rm -r ui_test/ui/ 2>/dev/null || true"
|
||||||
|
//go:generate mkdir ui_test/ui
|
||||||
|
//go:generate sh -c "cp ui/static/*.ts ui_test/ui/"
|
||||||
|
//go:generate tsc -p ui_test
|
||||||
|
//go:generate rm -r ui_test/ui/
|
||||||
|
//go:generate cp ui_test/lib/ui.css 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 {
|
||||||
|
if f, err := fs.Sub(_staticTest, "ui_test/static"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
} else {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
func testUIRoutes(mux *http.ServeMux) {
|
||||||
|
mux.Handle("GET /test/", http.StripPrefix("/test", http.FileServer(http.FS(staticTest))))
|
||||||
|
}
|
||||||
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) {}
|
||||||
@@ -15,12 +15,7 @@ func serveWebUI(w http.ResponseWriter, r *http.Request) {
|
|||||||
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
|
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/static/style.css":
|
case "/static/style.css":
|
||||||
darkTheme := r.CookiesNamed("dark_theme")
|
http.ServeFileFS(w, r, content, "ui/static/style.css")
|
||||||
if len(darkTheme) > 0 && darkTheme[0].Value == "true" {
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/dark.css")
|
|
||||||
} else {
|
|
||||||
http.ServeFileFS(w, r, content, "ui/static/light.css")
|
|
||||||
}
|
|
||||||
case "/favicon.ico":
|
case "/favicon.ico":
|
||||||
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":
|
||||||
|
|||||||
@@ -2,34 +2,56 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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/style.css">
|
||||||
<title>Hakurei PkgServer</title>
|
<title>Hakurei PkgServer</title>
|
||||||
<script src="static/index.js"></script>
|
<script src="static/index.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Hakurei PkgServer</h1>
|
<h1>Hakurei PkgServer</h1>
|
||||||
|
<div class="top-controls" id="top-controls-regular">
|
||||||
<table id="pkg-list">
|
<p>Showing entries <span id="entry-counter"></span>.</p>
|
||||||
<tr><td>Loading...</td></tr>
|
<span id="search-bar">
|
||||||
</table>
|
<label for="search">Search: </label>
|
||||||
<p>Showing entries <span id="entry-counter"></span>.</p>
|
<input type="text" name="search" id="search"/>
|
||||||
<span class="bottom-nav"><a href="javascript:prevPage()">« Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next »</a></span>
|
<button onclick="doSearch()">Find</button>
|
||||||
<span><label for="count">Entries per page: </label><select name="count" id="count">
|
<label for="include-desc">Include descriptions: </label>
|
||||||
|
<input type="checkbox" name="include-desc" id="include-desc" checked/>
|
||||||
|
</span>
|
||||||
|
<div><label for="count">Entries per page: </label><select name="count" id="count">
|
||||||
<option value="10">10</option>
|
<option value="10">10</option>
|
||||||
<option value="20">20</option>
|
<option value="20">20</option>
|
||||||
<option value="30">30</option>
|
<option value="30">30</option>
|
||||||
<option value="50">50</option>
|
<option value="50">50</option>
|
||||||
</select></span>
|
</select></div>
|
||||||
<span><label for="sort">Sort by: </label><select name="sort" id="sort">
|
<div><label for="sort">Sort by: </label><select name="sort" id="sort">
|
||||||
<option value="0">Definition (ascending)</option>
|
<option value="0">Definition (ascending)</option>
|
||||||
<option value="1">Definition (descending)</option>
|
<option value="1">Definition (descending)</option>
|
||||||
<option value="2">Name (ascending)</option>
|
<option value="2">Name (ascending)</option>
|
||||||
<option value="3">Name (descending)</option>
|
<option value="3">Name (descending)</option>
|
||||||
<option value="4">Size (ascending)</option>
|
<option value="4">Size (ascending)</option>
|
||||||
<option value="5">Size (descending)</option>
|
<option value="5">Size (descending)</option>
|
||||||
</select></span>
|
</select></div>
|
||||||
</body>
|
</div>
|
||||||
|
<div class="top-controls" id="search-top-controls" hidden>
|
||||||
|
<p>Showing search results <span id="search-entry-counter"></span> for query "<span id="search-query"></span>".</p>
|
||||||
|
<button onclick="exitSearch()">Back</button>
|
||||||
|
<div><label for="search-count">Entries per page: </label><select name="search-count" id="search-count">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
</select></div>
|
||||||
|
<p>Sorted by best match</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-controls"><a href="javascript:prevPage()">« Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next »</a></div>
|
||||||
|
<table id="pkg-list">
|
||||||
|
<tr><td>Loading...</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="page-controls"><a href="javascript:prevPage()">« Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next »</a></div>
|
||||||
<footer>
|
<footer>
|
||||||
<p>©<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
|
<p>©<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script>main();</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
331
cmd/pkgserver/ui/index.ts
Normal file
331
cmd/pkgserver/ui/index.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
interface PackageIndexEntry {
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
description?: string
|
||||||
|
website?: string
|
||||||
|
version?: string
|
||||||
|
report?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement {
|
||||||
|
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
|
||||||
|
let s = entry.size != null && entry.size > 0 ? `<p>Size: ${toByteSizeString(entry.size)} (${entry.size})</p>` : ""
|
||||||
|
let n: string
|
||||||
|
let d: string
|
||||||
|
if ('name_matches' in entry) {
|
||||||
|
n = `<h2>${nameMatches(entry as SearchResult)} ${v}</h2>`
|
||||||
|
} else {
|
||||||
|
n = `<h2>${escapeHtml(entry.name)} ${v}</h2>`
|
||||||
|
}
|
||||||
|
if ('desc_matches' in entry && STATE.getIncludeDescriptions()) {
|
||||||
|
d = descMatches(entry as SearchResult)
|
||||||
|
} else {
|
||||||
|
d = (entry as PackageIndexEntry).description != null ? `<p>${escapeHtml((entry as PackageIndexEntry).description)}</p>` : ""
|
||||||
|
}
|
||||||
|
let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
|
||||||
|
let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
|
||||||
|
let row = <HTMLTableRowElement>(document.createElement('tr'))
|
||||||
|
row.innerHTML = `<td>
|
||||||
|
${n}
|
||||||
|
${d}
|
||||||
|
${s}
|
||||||
|
${w}
|
||||||
|
${r}
|
||||||
|
</td>`
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameMatches(sr: SearchResult): string {
|
||||||
|
return markMatches(sr.name, sr.name_matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
function descMatches(sr: SearchResult): string {
|
||||||
|
return markMatches(sr.description!, sr.desc_matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMatches(str: string, indices: [number, number][]): string {
|
||||||
|
if (indices == null) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
let out: string = ""
|
||||||
|
let j = 0
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (j < indices.length) {
|
||||||
|
if (i === indices[j][0]) {
|
||||||
|
out += `<mark>${escapeHtmlChar(str[i])}`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (i === indices[j][1]) {
|
||||||
|
out += `</mark>${escapeHtmlChar(str[i])}`
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += escapeHtmlChar(str[i])
|
||||||
|
}
|
||||||
|
if (indices[j] !== undefined) {
|
||||||
|
out += "</mark>"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function toByteSizeString(bytes: number): string {
|
||||||
|
if (bytes == null) return `unspecified`
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB`
|
||||||
|
if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB`
|
||||||
|
if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB`
|
||||||
|
if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB`
|
||||||
|
return "not only is it big, it's large"
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_VERSION = 1
|
||||||
|
const ENDPOINT = `/api/v${API_VERSION}`
|
||||||
|
|
||||||
|
interface InfoPayload {
|
||||||
|
count?: number
|
||||||
|
hakurei_version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function infoRequest(): Promise<InfoPayload> {
|
||||||
|
const res = await fetch(`${ENDPOINT}/info`)
|
||||||
|
const payload = await res.json()
|
||||||
|
return payload as InfoPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetPayload {
|
||||||
|
values?: PackageIndexEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortOrders {
|
||||||
|
DeclarationAscending,
|
||||||
|
DeclarationDescending,
|
||||||
|
NameAscending,
|
||||||
|
NameDescending
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
|
||||||
|
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
|
||||||
|
const payload = await res.json()
|
||||||
|
return payload as GetPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult extends PackageIndexEntry {
|
||||||
|
name_matches: [number, number][]
|
||||||
|
desc_matches: [number, number][]
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchPayload {
|
||||||
|
count?: number
|
||||||
|
values?: SearchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise<SearchPayload> {
|
||||||
|
const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
exitSearch()
|
||||||
|
alert("invalid search query!")
|
||||||
|
return Promise.reject(res.statusText)
|
||||||
|
}
|
||||||
|
const payload = await res.json()
|
||||||
|
return payload as SearchPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
class State {
|
||||||
|
entriesPerPage: number = 10
|
||||||
|
entryIndex: number = 0
|
||||||
|
maxTotal: number = 0
|
||||||
|
maxEntries: number = 0
|
||||||
|
sort: SortOrders = SortOrders.DeclarationAscending
|
||||||
|
search: boolean = false
|
||||||
|
|
||||||
|
getEntriesPerPage(): number {
|
||||||
|
return this.entriesPerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntriesPerPage(entriesPerPage: number) {
|
||||||
|
this.entriesPerPage = entriesPerPage
|
||||||
|
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntryIndex(): number {
|
||||||
|
return this.entryIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntryIndex(entryIndex: number) {
|
||||||
|
this.entryIndex = entryIndex
|
||||||
|
this.updatePage()
|
||||||
|
this.updateRange()
|
||||||
|
this.updateListings()
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxTotal(): number {
|
||||||
|
return this.maxTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaxTotal(max: number) {
|
||||||
|
this.maxTotal = max
|
||||||
|
}
|
||||||
|
|
||||||
|
getSortOrder(): SortOrders {
|
||||||
|
return this.sort
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortOrder(sortOrder: SortOrders) {
|
||||||
|
this.sort = sortOrder
|
||||||
|
this.setEntryIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePage() {
|
||||||
|
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
|
||||||
|
for (let e of document.getElementsByClassName("page-number")) {
|
||||||
|
(e as HTMLInputElement).value = String(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRange() {
|
||||||
|
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal())
|
||||||
|
document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}`
|
||||||
|
if (this.search) {
|
||||||
|
document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}`
|
||||||
|
document.getElementById("search-query")!.innerHTML = `<code>${escapeHtml(this.getSearchQuery())}</code>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSearchQuery(): string {
|
||||||
|
let queryString = document.getElementById("search")!;
|
||||||
|
return (queryString as HTMLInputElement).value
|
||||||
|
}
|
||||||
|
|
||||||
|
getIncludeDescriptions(): boolean {
|
||||||
|
let includeDesc = document.getElementById("include-desc")!;
|
||||||
|
return (includeDesc as HTMLInputElement).checked
|
||||||
|
}
|
||||||
|
|
||||||
|
updateListings() {
|
||||||
|
if (this.search) {
|
||||||
|
searchRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSearchQuery(), this.getIncludeDescriptions())
|
||||||
|
.then(res => {
|
||||||
|
let table = document.getElementById("pkg-list")!
|
||||||
|
table.innerHTML = ''
|
||||||
|
for (let row of res.values!) {
|
||||||
|
table.appendChild(entryToHTML(row))
|
||||||
|
}
|
||||||
|
STATE.maxTotal = res.count!
|
||||||
|
STATE.updateRange()
|
||||||
|
if(res.count! < 1) {
|
||||||
|
exitSearch()
|
||||||
|
alert("no results found!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
|
||||||
|
.then(res => {
|
||||||
|
let table = document.getElementById("pkg-list")!
|
||||||
|
table.innerHTML = ''
|
||||||
|
for (let row of res.values!) {
|
||||||
|
table.appendChild(entryToHTML(row))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let STATE: State
|
||||||
|
|
||||||
|
|
||||||
|
function lastPageIndex(): number {
|
||||||
|
return Math.floor(STATE.getMaxTotal() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPage(page: number) {
|
||||||
|
STATE.setEntryIndex(Math.max(0, Math.min(STATE.getEntriesPerPage() * (page - 1), lastPageIndex())))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function escapeHtml(str?: string): string {
|
||||||
|
let out: string = ''
|
||||||
|
if (str == undefined) return ""
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
out += escapeHtmlChar(str[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlChar(char: string): string {
|
||||||
|
if (char.length != 1) return char
|
||||||
|
switch (char[0]) {
|
||||||
|
case '&':
|
||||||
|
return "&"
|
||||||
|
case '<':
|
||||||
|
return "<"
|
||||||
|
case '>':
|
||||||
|
return ">"
|
||||||
|
case '"':
|
||||||
|
return """
|
||||||
|
case "'":
|
||||||
|
return "'"
|
||||||
|
default:
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPage() {
|
||||||
|
STATE.setEntryIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
let index = STATE.getEntryIndex()
|
||||||
|
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastPage() {
|
||||||
|
STATE.setEntryIndex(lastPageIndex())
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
let index = STATE.getEntryIndex()
|
||||||
|
STATE.setEntryIndex(Math.min(lastPageIndex(), index + STATE.getEntriesPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
|
||||||
|
document.getElementById("search-top-controls")!.toggleAttribute("hidden");
|
||||||
|
STATE.search = true;
|
||||||
|
STATE.setEntryIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitSearch() {
|
||||||
|
document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
|
||||||
|
document.getElementById("search-top-controls")!.toggleAttribute("hidden");
|
||||||
|
STATE.search = false;
|
||||||
|
STATE.setMaxTotal(STATE.maxEntries)
|
||||||
|
STATE.setEntryIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
STATE = new State()
|
||||||
|
infoRequest()
|
||||||
|
.then(res => {
|
||||||
|
STATE.maxEntries = res.count!
|
||||||
|
STATE.setMaxTotal(STATE.maxEntries)
|
||||||
|
document.getElementById("hakurei-version")!.textContent = res.hakurei_version!
|
||||||
|
STATE.updateRange()
|
||||||
|
STATE.updateListings()
|
||||||
|
})
|
||||||
|
for (let e of document.getElementsByClassName("page-number")) {
|
||||||
|
e.addEventListener("change", (_) => {
|
||||||
|
setPage(parseInt((e as HTMLInputElement).value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
document.getElementById("count")?.addEventListener("change", (event) => {
|
||||||
|
STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
|
||||||
|
})
|
||||||
|
document.getElementById("sort")?.addEventListener("change", (event) => {
|
||||||
|
STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
|
||||||
|
})
|
||||||
|
document.getElementById("search")?.addEventListener("keyup", (event) => {
|
||||||
|
if (event.key === 'Enter') doSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@use 'common';
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: #2c2c2c;
|
|
||||||
color: ghostwhite;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
class PackageIndexEntry {
|
|
||||||
name: string
|
|
||||||
size: number | null
|
|
||||||
description: string | null
|
|
||||||
website: string | null
|
|
||||||
version: string | null
|
|
||||||
report: boolean
|
|
||||||
}
|
|
||||||
function toHTML(entry: PackageIndexEntry): HTMLTableRowElement {
|
|
||||||
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
|
|
||||||
let s = entry.size != null ? `<p>Size: ${toByteSizeString(entry.size)} (${entry.size})</p>` : ""
|
|
||||||
let d = entry.description != null ? `<p>${escapeHtml(entry.description)}</p>` : ""
|
|
||||||
let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
|
|
||||||
let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
|
|
||||||
let row = <HTMLTableRowElement>(document.createElement('tr'))
|
|
||||||
row.innerHTML = `<td>
|
|
||||||
<h2>${escapeHtml(entry.name)} ${v}</h2>
|
|
||||||
${d}
|
|
||||||
${s}
|
|
||||||
${w}
|
|
||||||
${r}
|
|
||||||
</td>`
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
function toByteSizeString(bytes: number): string {
|
|
||||||
if(bytes == null || bytes < 1024) return `${bytes}B`
|
|
||||||
if(bytes < Math.pow(1024, 2)) return `${(bytes/1024).toFixed(2)}kiB`
|
|
||||||
if(bytes < Math.pow(1024, 3)) return `${(bytes/Math.pow(1024, 2)).toFixed(2)}MiB`
|
|
||||||
if(bytes < Math.pow(1024, 4)) return `${(bytes/Math.pow(1024, 3)).toFixed(2)}GiB`
|
|
||||||
if(bytes < Math.pow(1024, 5)) return `${(bytes/Math.pow(1024, 4)).toFixed(2)}TiB`
|
|
||||||
return "not only is it big, it's large"
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_VERSION = 1
|
|
||||||
const ENDPOINT = `/api/v${API_VERSION}`
|
|
||||||
class InfoPayload {
|
|
||||||
count: number
|
|
||||||
hakurei_version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function infoRequest(): Promise<InfoPayload> {
|
|
||||||
const res = await fetch(`${ENDPOINT}/info`)
|
|
||||||
const payload = await res.json()
|
|
||||||
return payload as InfoPayload
|
|
||||||
}
|
|
||||||
class GetPayload {
|
|
||||||
values: PackageIndexEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SortOrders {
|
|
||||||
DeclarationAscending,
|
|
||||||
DeclarationDescending,
|
|
||||||
NameAscending,
|
|
||||||
NameDescending
|
|
||||||
}
|
|
||||||
async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
|
|
||||||
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
|
|
||||||
const payload = await res.json()
|
|
||||||
return payload as GetPayload
|
|
||||||
}
|
|
||||||
class State {
|
|
||||||
entriesPerPage: number = 10
|
|
||||||
entryIndex: number = 0
|
|
||||||
maxEntries: number = 0
|
|
||||||
sort: SortOrders = SortOrders.DeclarationAscending
|
|
||||||
|
|
||||||
getEntriesPerPage(): number {
|
|
||||||
return this.entriesPerPage
|
|
||||||
}
|
|
||||||
setEntriesPerPage(entriesPerPage: number) {
|
|
||||||
this.entriesPerPage = entriesPerPage
|
|
||||||
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
|
|
||||||
}
|
|
||||||
getEntryIndex(): number {
|
|
||||||
return this.entryIndex
|
|
||||||
}
|
|
||||||
setEntryIndex(entryIndex: number) {
|
|
||||||
this.entryIndex = entryIndex
|
|
||||||
this.updatePage()
|
|
||||||
this.updateRange()
|
|
||||||
this.updateListings()
|
|
||||||
}
|
|
||||||
getMaxEntries(): number {
|
|
||||||
return this.maxEntries
|
|
||||||
}
|
|
||||||
setMaxEntries(max: number) {
|
|
||||||
this.maxEntries = max
|
|
||||||
}
|
|
||||||
getSortOrder(): SortOrders {
|
|
||||||
return this.sort
|
|
||||||
}
|
|
||||||
setSortOrder(sortOrder: SortOrders) {
|
|
||||||
this.sort = sortOrder
|
|
||||||
this.setEntryIndex(0)
|
|
||||||
}
|
|
||||||
updatePage() {
|
|
||||||
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
|
|
||||||
document.getElementById("page-number").innerText = String(page)
|
|
||||||
}
|
|
||||||
updateRange() {
|
|
||||||
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries())
|
|
||||||
document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`
|
|
||||||
}
|
|
||||||
updateListings() {
|
|
||||||
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
|
|
||||||
.then(res => {
|
|
||||||
let table = document.getElementById("pkg-list")
|
|
||||||
table.innerHTML = ''
|
|
||||||
res.values.forEach((row) => {
|
|
||||||
table.appendChild(toHTML(row))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
let STATE: State
|
|
||||||
|
|
||||||
function prevPage() {
|
|
||||||
let index = STATE.getEntryIndex()
|
|
||||||
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
|
|
||||||
}
|
|
||||||
function nextPage() {
|
|
||||||
let index = STATE.getEntryIndex()
|
|
||||||
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()))
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
|
||||||
if(str === undefined) return ""
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
STATE = new State()
|
|
||||||
infoRequest()
|
|
||||||
.then(res => {
|
|
||||||
STATE.setMaxEntries(res.count)
|
|
||||||
document.getElementById("hakurei-version").innerText = res.hakurei_version
|
|
||||||
STATE.updateRange()
|
|
||||||
STATE.updateListings()
|
|
||||||
})
|
|
||||||
|
|
||||||
document.getElementById("count").addEventListener("change", (event) => {
|
|
||||||
STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
|
|
||||||
})
|
|
||||||
document.getElementById("sort").addEventListener("change", (event) => {
|
|
||||||
STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@use 'common';
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: #d3d3d3;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
21
cmd/pkgserver/ui/static/style.css
Normal file
21
cmd/pkgserver/ui/static/style.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.page-number {
|
||||||
|
width: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.page-number {
|
||||||
|
width: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
color: ghostwhite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html {
|
||||||
|
background-color: #d3d3d3;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2024"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
cmd/pkgserver/ui/tsconfig.json
Normal file
8
cmd/pkgserver/ui/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"strict": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"outDir": "static"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 && tsc -p ui/static"
|
//go:generate tsc -p ui
|
||||||
//go:embed ui/*
|
//go:embed ui/*
|
||||||
var content embed.FS
|
var content embed.FS
|
||||||
|
|||||||
2
cmd/pkgserver/ui_test/all_tests.ts
Normal file
2
cmd/pkgserver/ui_test/all_tests.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Import all test files to register their test suites.
|
||||||
|
import "./index_test.js";
|
||||||
2
cmd/pkgserver/ui_test/index_test.ts
Normal file
2
cmd/pkgserver/ui_test/index_test.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { suite, test } from "./lib/test.js";
|
||||||
|
import "./ui/index.js";
|
||||||
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, GLOBAL_REGISTRAR } 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);
|
||||||
|
GLOBAL_REGISTRAR.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>
|
||||||
3
cmd/pkgserver/ui_test/lib/go_test_entrypoint.ts
Normal file
3
cmd/pkgserver/ui_test/lib/go_test_entrypoint.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import "../all_tests.js";
|
||||||
|
import { GoTestReporter, GLOBAL_REGISTRAR } from "./test.js";
|
||||||
|
GLOBAL_REGISTRAR.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 |
403
cmd/pkgserver/ui_test/lib/test.ts
Normal file
403
cmd/pkgserver/ui_test/lib/test.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// DSL
|
||||||
|
|
||||||
|
type TestTree = TestGroup | Test;
|
||||||
|
type TestGroup = { name: string; children: TestTree[] };
|
||||||
|
type Test = { name: string; test: (t: TestController) => void };
|
||||||
|
|
||||||
|
export class TestRegistrar {
|
||||||
|
#suites: TestGroup[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#suites = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
suite(name: string, children: TestTree[]) {
|
||||||
|
checkDuplicates(name, children);
|
||||||
|
this.#suites.push({ name, children });
|
||||||
|
}
|
||||||
|
|
||||||
|
run(reporter: Reporter) {
|
||||||
|
reporter.register(this.#suites);
|
||||||
|
for (const suite of this.#suites) {
|
||||||
|
for (const c of suite.children) runTests(reporter, [suite.name], c);
|
||||||
|
}
|
||||||
|
reporter.finalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let GLOBAL_REGISTRAR = new TestRegistrar();
|
||||||
|
|
||||||
|
// Register a suite in the global registrar.
|
||||||
|
export function suite(name: string, children: TestTree[]) {
|
||||||
|
GLOBAL_REGISTRAR.suite(name, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function group(name: string, children: TestTree[]): TestTree {
|
||||||
|
checkDuplicates(name, children);
|
||||||
|
return { name, children };
|
||||||
|
}
|
||||||
|
export const context = group;
|
||||||
|
export const describe = group;
|
||||||
|
|
||||||
|
export function test(name: string, test: (t: TestController) => void): TestTree {
|
||||||
|
return { name, test };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDuplicates(parent: string, names: { name: string }[]) {
|
||||||
|
let seen = new Set<string>();
|
||||||
|
for (const { name } of names) {
|
||||||
|
if (seen.has(name)) {
|
||||||
|
throw new RangeError(`duplicate name '${name}' in '${parent}'`);
|
||||||
|
}
|
||||||
|
seen.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestState = "success" | "failure" | "skip";
|
||||||
|
|
||||||
|
class AbortSentinel {}
|
||||||
|
|
||||||
|
export class TestController {
|
||||||
|
#state: TestState;
|
||||||
|
logs: string[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#state = "success";
|
||||||
|
this.logs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): TestState {
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
this.#state = "failure";
|
||||||
|
}
|
||||||
|
|
||||||
|
failed(): boolean {
|
||||||
|
return this.#state === "failure";
|
||||||
|
}
|
||||||
|
|
||||||
|
failNow(): never {
|
||||||
|
this.fail();
|
||||||
|
throw new AbortSentinel();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string) {
|
||||||
|
this.logs.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string) {
|
||||||
|
this.log(message);
|
||||||
|
this.fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
fatal(message: string): never {
|
||||||
|
this.log(message);
|
||||||
|
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
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
state: TestState;
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests(reporter: Reporter, parents: string[], node: TestTree) {
|
||||||
|
const path = [...parents, node.name];
|
||||||
|
if ("children" in node) {
|
||||||
|
for (const c of node.children) runTests(reporter, path, c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let controller = new TestController();
|
||||||
|
try {
|
||||||
|
node.test(controller);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof AbortSentinel)) {
|
||||||
|
controller.error(extractExceptionString(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reporter.update(path, { state: controller.getState(), logs: controller.logs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExceptionString(e: any): string {
|
||||||
|
// String() instead of .toString() as null and undefined don't have
|
||||||
|
// properties.
|
||||||
|
const s = String(e);
|
||||||
|
if (!(e instanceof Error && e.stack)) return s;
|
||||||
|
// v8 (Chromium, NodeJS) includes the error message, while Firefox and
|
||||||
|
// WebKit do not.
|
||||||
|
if (e.stack.startsWith(s)) return e.stack;
|
||||||
|
return `${s}\n${e.stack}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Reporting
|
||||||
|
|
||||||
|
export interface Reporter {
|
||||||
|
register(suites: TestGroup[]): void;
|
||||||
|
update(path: string[], result: TestResult): void;
|
||||||
|
finalize(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoOpReporter implements Reporter {
|
||||||
|
suites: TestGroup[];
|
||||||
|
results: ({ path: string[] } & TestResult)[];
|
||||||
|
finalized: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.suites = [];
|
||||||
|
this.results = [];
|
||||||
|
this.finalized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(suites: TestGroup[]) {
|
||||||
|
this.suites = suites;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(path: string[], result: TestResult) {
|
||||||
|
this.results.push({ path, ...result });
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize() {
|
||||||
|
this.finalized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stream {
|
||||||
|
writeln(s: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEP = " ❯ ";
|
||||||
|
|
||||||
|
export class StreamReporter implements Reporter {
|
||||||
|
stream: Stream;
|
||||||
|
verbose: boolean;
|
||||||
|
#successes: ({ path: string[] } & TestResult)[];
|
||||||
|
#failures: ({ path: string[] } & TestResult)[];
|
||||||
|
#skips: ({ path: string[] } & TestResult)[];
|
||||||
|
|
||||||
|
constructor(stream: Stream, verbose: boolean = false) {
|
||||||
|
this.stream = stream;
|
||||||
|
this.verbose = verbose;
|
||||||
|
this.#successes = [];
|
||||||
|
this.#failures = [];
|
||||||
|
this.#skips = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
succeeded(): boolean {
|
||||||
|
return this.#successes.length > 0 && this.#failures.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(suites: TestGroup[]) {}
|
||||||
|
|
||||||
|
update(path: string[], result: TestResult) {
|
||||||
|
if (path.length === 0) throw new RangeError("path is empty");
|
||||||
|
const pathStr = path.join(SEP);
|
||||||
|
switch (result.state) {
|
||||||
|
case "success":
|
||||||
|
this.#successes.push({ path, ...result });
|
||||||
|
if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`);
|
||||||
|
break;
|
||||||
|
case "failure":
|
||||||
|
this.#failures.push({ path, ...result });
|
||||||
|
this.stream.writeln(`⚠️ ${pathStr}`);
|
||||||
|
break;
|
||||||
|
case "skip":
|
||||||
|
this.#skips.push({ path, ...result });
|
||||||
|
this.stream.writeln(`⏭️ ${pathStr}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"] }]
|
||||||
|
// into { "a ❯ b": ["c", "d"] }.
|
||||||
|
let pathMap = new Map<string, ({ name: string } & TestResult)[]>();
|
||||||
|
for (const t of data) {
|
||||||
|
if (t.path.length === 0) throw new RangeError("path is empty");
|
||||||
|
const key = t.path.slice(0, -1).join(SEP);
|
||||||
|
if (!pathMap.has(key)) pathMap.set(key, []);
|
||||||
|
pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stream.writeln("");
|
||||||
|
this.stream.writeln(name.toUpperCase());
|
||||||
|
this.stream.writeln("=".repeat(name.length));
|
||||||
|
|
||||||
|
for (let [path, tests] of pathMap) {
|
||||||
|
if (ignoreEmpty) tests = tests.filter((t) => t.logs.length);
|
||||||
|
if (tests.length === 0) continue;
|
||||||
|
if (tests.length === 1) {
|
||||||
|
this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false);
|
||||||
|
} else {
|
||||||
|
this.stream.writeln(path);
|
||||||
|
for (const t of tests) this.#writeOutput(t, " - ", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) {
|
||||||
|
let output = "";
|
||||||
|
if (test.logs.length) {
|
||||||
|
// Individual logs might span multiple lines, so join them together
|
||||||
|
// then split it again.
|
||||||
|
const logStr = test.logs.join("\n");
|
||||||
|
const lines = logStr.split("\n");
|
||||||
|
if (lines.length <= 1) {
|
||||||
|
output = `: ${logStr}`;
|
||||||
|
} else {
|
||||||
|
const padding = nested ? " " : " ";
|
||||||
|
output = ":\n" + lines.map((line) => padding + line).join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.stream.writeln(`${prefix}${test.name}${output}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertGetElementById(id: string): HTMLElement {
|
||||||
|
let elem = document.getElementById(id);
|
||||||
|
if (elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`);
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DOMReporter implements Reporter {
|
||||||
|
register(suites: TestGroup[]) {}
|
||||||
|
|
||||||
|
update(path: string[], result: TestResult) {
|
||||||
|
if (path.length === 0) throw new RangeError("path is empty");
|
||||||
|
if (result.state === "skip") {
|
||||||
|
assertGetElementById("skip-counter-text").hidden = false;
|
||||||
|
}
|
||||||
|
const counter = assertGetElementById(`${result.state}-counter`);
|
||||||
|
counter.innerText = (Number(counter.innerText) + 1).toString();
|
||||||
|
|
||||||
|
let parent = assertGetElementById("root");
|
||||||
|
for (const node of path) {
|
||||||
|
let child: HTMLDetailsElement | null = null;
|
||||||
|
let summary: HTMLElement | null = null;
|
||||||
|
let d: Element;
|
||||||
|
outer: for (d of parent.children) {
|
||||||
|
if (!(d instanceof HTMLDetailsElement)) continue;
|
||||||
|
for (const s of d.children) {
|
||||||
|
if (!(s instanceof HTMLElement)) continue;
|
||||||
|
if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue;
|
||||||
|
child = d;
|
||||||
|
summary = s;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!child) {
|
||||||
|
child = document.createElement("details");
|
||||||
|
child.className = "test-node";
|
||||||
|
child.ariaRoleDescription = "test";
|
||||||
|
summary = document.createElement("summary");
|
||||||
|
summary.appendChild(document.createTextNode(node));
|
||||||
|
summary.ariaRoleDescription = "test name";
|
||||||
|
child.appendChild(summary);
|
||||||
|
parent.appendChild(child);
|
||||||
|
}
|
||||||
|
if (!summary) throw new Error("unreachable as assigned above");
|
||||||
|
|
||||||
|
switch (result.state) {
|
||||||
|
case "failure":
|
||||||
|
child.open = true;
|
||||||
|
child.classList.add("failure");
|
||||||
|
child.classList.remove("skip");
|
||||||
|
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");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.classList.add("test-desc");
|
||||||
|
if (result.logs.length) {
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.appendChild(document.createTextNode(result.logs.join("\n")));
|
||||||
|
p.appendChild(pre);
|
||||||
|
} else {
|
||||||
|
p.classList.add("italic");
|
||||||
|
p.appendChild(document.createTextNode("No output."));
|
||||||
|
}
|
||||||
|
parent.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoNode {
|
||||||
|
name: string;
|
||||||
|
subtests?: GoNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to display results via `go test`, via some glue code from the Go side.
|
||||||
|
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.
|
||||||
|
static serialize(node: TestTree): GoNode {
|
||||||
|
return {
|
||||||
|
name: node.name,
|
||||||
|
subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
update(path: string[], result: TestResult) {
|
||||||
|
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, state, logs: result.logs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize() {
|
||||||
|
console.log("null");
|
||||||
|
}
|
||||||
|
}
|
||||||
87
cmd/pkgserver/ui_test/lib/ui.css
Normal file
87
cmd/pkgserver/ui_test/lib/ui.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* When updating the theme colors, also update them in success-closed.svg and
|
||||||
|
* success-open.svg!
|
||||||
|
*/
|
||||||
|
|
||||||
|
: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("/test/success-closed.svg") / "success";
|
||||||
|
}
|
||||||
|
&.success[open] > summary::marker {
|
||||||
|
content: url("/test/success-open.svg") / "success";
|
||||||
|
}
|
||||||
|
&.failure > summary::marker {
|
||||||
|
color: red;
|
||||||
|
content: url("/test/failure-closed.svg") / "failure";
|
||||||
|
}
|
||||||
|
&.failure[open] > summary::marker {
|
||||||
|
content: url("/test/failure-open.svg") / "failure";
|
||||||
|
}
|
||||||
|
&.skip > summary::marker {
|
||||||
|
color: blue;
|
||||||
|
content: url("/test/skip-closed.svg") / "skip";
|
||||||
|
}
|
||||||
|
&.skip[open] > summary::marker {
|
||||||
|
content: url("/test/skip-open.svg") / "skip";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.test-desc {
|
||||||
|
margin: 0 0 0 1rem;
|
||||||
|
padding: 2px 0;
|
||||||
|
> pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
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="/test/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" 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, GLOBAL_REGISTRAR } from "/test/lib/test.js";
|
||||||
|
GLOBAL_REGISTRAR.run(new DOMReporter());
|
||||||
|
</script>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
cmd/pkgserver/ui_test/tsconfig.json
Normal file
8
cmd/pkgserver/ui_test/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"strict": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"outDir": "static"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user