feat(github): give readers two issue forms and link to them
Nothing a reader marks, types or ticks ever leaves their browser, so there is no account and no server-side record to look anything up in after a report arrives. Whatever the form captured is all there will ever be — which is the argument for structured forms over a blank box, and for the bug form carrying the footer's "event data last refreshed" line as a prefill. A stale calendar and a genuinely wrong parse are indistinguishable from outside, eight sources cannot be fetched from CI at all, and that line is the only thing that separates the two cases. The feature form states the three constraints up front, because "sync across my devices" and "have a model read the page" both have settled answers and a reader deserves them before typing rather than after. It also points source requests at the assessed-and-declined table: the loudest feedback on the first release was that the game list is too short, so that is the request that arrives most, and a wiki that forbids automated access or only archives finished events cannot be used no matter how often it is asked for. Blank issues stay enabled. A form that turns someone away loses the report, not the noise. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32a0683824
commit
02dd7b3ed4
@@ -0,0 +1,103 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: A wrong date, a missing event, a lost tick, or anything else the app got wrong.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting this.
|
||||||
|
|
||||||
|
**If a date looks wrong, please open the event and check its source link first.**
|
||||||
|
This app does not compile schedules; it rearranges what a wiki published. If the wiki
|
||||||
|
page says the same thing, the fix belongs there — and it is still worth telling us,
|
||||||
|
because we may be able to use a better source for that game.
|
||||||
|
|
||||||
|
Nothing you have marked, typed or ticked leaves your browser, so there is no account
|
||||||
|
for us to look up. That means the details below are all we have to go on.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: What part of the app?
|
||||||
|
options:
|
||||||
|
- A wrong date or time
|
||||||
|
- An event is missing
|
||||||
|
- An event that should not be there
|
||||||
|
- Dailies, streaks or the checklist
|
||||||
|
- The list, calendar or sorting
|
||||||
|
- Events I entered myself
|
||||||
|
- Offline, installing, or updating
|
||||||
|
- Export or import
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: event
|
||||||
|
attributes:
|
||||||
|
label: Which game and event?
|
||||||
|
description: The game, and the event title exactly as the app shows it.
|
||||||
|
placeholder: "Genshin Impact — Mutual Aid in Bloom: Into the Frostlands"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened, and what did you expect?
|
||||||
|
description: |
|
||||||
|
For a date problem, the useful shape is: what this app shows, what the source page
|
||||||
|
shows, and which one you believe.
|
||||||
|
placeholder: |
|
||||||
|
The app says it ends 24 Aug, 09:00. The Game8 page says 31 Aug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: refreshed
|
||||||
|
attributes:
|
||||||
|
label: "\"Event data last refreshed\" line"
|
||||||
|
description: |
|
||||||
|
Copy it from the bottom of the page. Several sources cannot be fetched automatically,
|
||||||
|
so a stale calendar is a normal state with its own explanation — this line tells the
|
||||||
|
two cases apart before anyone goes looking for a parser bug.
|
||||||
|
placeholder: Event data last refreshed 15 Aug 2026, 04:12 — 2 days ago.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Source page
|
||||||
|
description: The wiki page the event links to, if the problem involves its data.
|
||||||
|
placeholder: https://game8.co/games/Genshin-Impact/archives/...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: where
|
||||||
|
attributes:
|
||||||
|
label: Where are you running it, and in what browser?
|
||||||
|
placeholder: stereotypicalcat.github.io/gacha-event-tracker — Firefox 141 on Android
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else
|
||||||
|
description: |
|
||||||
|
Screenshots help. If you attach an export file, remember it is a record of your own
|
||||||
|
progress — trim it to the event in question if you would rather not share the rest.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checks
|
||||||
|
attributes:
|
||||||
|
label: Before you post
|
||||||
|
options:
|
||||||
|
- label: I checked the event's source page, or this is not about a date.
|
||||||
|
required: false
|
||||||
|
- label: I searched the open issues for the same problem.
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Blank issues stay available on purpose. The two forms cover the reports we can
|
||||||
|
# act on fastest, but a reader who cannot fit what they saw into either of them
|
||||||
|
# should still be able to say so — a form that turns someone away loses the
|
||||||
|
# report, not the noise.
|
||||||
|
blank_issues_enabled: true
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Ask for a game, a source, or something the app should do.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Ideas are welcome. Three things are settled, though, so you know before you type:
|
||||||
|
|
||||||
|
- **No accounts, no logins, no server-side records.** Everything you mark stays in your
|
||||||
|
browser. "Sync across my devices" is answered by exporting a file and importing it on
|
||||||
|
the other device, not by an account.
|
||||||
|
- **No LLM reads any page.** Every date comes from deterministic parsers. A source that
|
||||||
|
cannot be parsed reliably gets no adapter, rather than a model guessing at it.
|
||||||
|
- **No date is ever invented.** An event whose end was not announced shows no end date.
|
||||||
|
That is the intended behaviour, not a gap to fill in.
|
||||||
|
|
||||||
|
**Asking for a game?** Include the page you would want it read from. Some sources have
|
||||||
|
already been assessed and declined — a wiki that forbids automated access, hides its
|
||||||
|
data behind a bot check, or only archives finished events cannot be used. The current
|
||||||
|
list is in `AGENTS.md` under "Sources assessed and declined", so you can check before
|
||||||
|
writing it up.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: kind
|
||||||
|
attributes:
|
||||||
|
label: What kind of request is this?
|
||||||
|
options:
|
||||||
|
- A game the app does not cover
|
||||||
|
- A better or extra source for a game it does cover
|
||||||
|
- The list, calendar or sorting
|
||||||
|
- Dailies, streaks or the checklist
|
||||||
|
- Events I enter myself
|
||||||
|
- Offline, installing, or updating
|
||||||
|
- Export or import
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to do?
|
||||||
|
description: |
|
||||||
|
The situation, rather than the solution. Knowing what you were doing when the app got
|
||||||
|
in your way often turns up a smaller fix than the one either of us would have guessed.
|
||||||
|
placeholder: |
|
||||||
|
I play six games and only two of them are on here, so I still end up opening tabs.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: What would you like it to do?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Source page
|
||||||
|
description: |
|
||||||
|
For a game or source request: the page that lists that game's events, with real dates
|
||||||
|
on it. A page whose newest event has already finished cannot be used.
|
||||||
|
placeholder: https://bluearchive.wiki/wiki/Events
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest
|
|||||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||||
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 480 tests
|
test/ 494 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -252,6 +252,11 @@ A source whose ToS forbids automated access does not get an adapter. Flag it and
|
|||||||
| `bluearchive.fandom.com` | **Declined.** Fetches and parses fine; the page is the problem. Its `Event/Event_List` is a JP-server archive whose newest entry ended 2026-02-18, so all 88 rows are history and it yields **zero** live or upcoming events. An adapter would put an empty lane on the calendar and, because the runner rejects a body that parses to nothing, report a broken source forever. Same failure as the Infinity Nikki Game8 page, further along |
|
| `bluearchive.fandom.com` | **Declined.** Fetches and parses fine; the page is the problem. Its `Event/Event_List` is a JP-server archive whose newest entry ended 2026-02-18, so all 88 rows are history and it yields **zero** live or upcoming events. An adapter would put an empty lane on the calendar and, because the runner rejects a body that parses to nothing, report a broken source forever. Same failure as the Infinity Nikki Game8 page, further along |
|
||||||
| `bluearchive.wiki`, `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h. `bluearchive.wiki` is the live Blue Archive source worth building — `Name (EN) \| Name (JP) \| Start date \| End date` with ISO dates, current to 2026-09-15 — but note it is Miraheze and its `robots.txt` **disallows** `/w/` and `/*?action=`, so the API route is closed there and only the rendered `/wiki/Events` page is permitted. It also lists JP and Global in separate tables, which is the akwiki hazard: publishing the wrong server's date |
|
| `bluearchive.wiki`, `prydwen.gg`, `gametora.com` | **Cleared, unbuilt.** `User-agent: *` allows the paths we would want. prydwen sets `Crawl-delay: 10`, far below our one-per-6h. `bluearchive.wiki` is the live Blue Archive source worth building — `Name (EN) \| Name (JP) \| Start date \| End date` with ISO dates, current to 2026-09-15 — but note it is Miraheze and its `robots.txt` **disallows** `/w/` and `/*?action=`, so the API route is closed there and only the rendered `/wiki/Events` page is permitted. It also lists JP and Global in separate tables, which is the akwiki hazard: publishing the wrong server's date |
|
||||||
|
|
||||||
|
`.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source
|
||||||
|
request can be checked against it before anyone writes it up — the loudest feedback on the first
|
||||||
|
release was "not enough games" (`docs/FEEDBACK.md`), which makes this the request that arrives most.
|
||||||
|
Keep the heading if the section moves.
|
||||||
|
|
||||||
wiki.gg hosts (`arknights`, `endfield`) carry `Content-Signal: search=yes, ai-train=no, use=reference`
|
wiki.gg hosts (`arknights`, `endfield`) carry `Content-Signal: search=yes, ai-train=no, use=reference`
|
||||||
with `Allow: /`, and disallow `ClaudeBot` and other AI crawlers by name. Our fetcher is neither: it
|
with `Allow: /`, and disallow `ClaudeBot` and other AI crawlers by name. Our fetcher is neither: it
|
||||||
trains nothing, and no LLM reads the page content — constraint 2 is what keeps that true, so it is
|
trains nothing, and no LLM reads the page content — constraint 2 is what keeps that true, so it is
|
||||||
|
|||||||
@@ -209,6 +209,27 @@ six-hour floor is checked per source, there are no retries (a retry is a second
|
|||||||
`robots.txt` that cannot be read means *do not fetch* rather than *assume yes*. Text scraped from a
|
`robots.txt` that cannot be read means *do not fetch* rather than *assume yes*. Text scraped from a
|
||||||
page is sanitized at the ingest boundary before it reaches the feed, the browser or your disk.
|
page is sanitized at the ingest boundary before it reaches the feed, the browser or your disk.
|
||||||
|
|
||||||
|
## Found a problem, or want something?
|
||||||
|
|
||||||
|
- **[Report a problem](https://github.com/StereotypicalCat/gacha-event-tracker/issues/new?template=bug_report.yml)**
|
||||||
|
— a wrong date, a missing event, a lost tick, anything the app got wrong.
|
||||||
|
- **[Request a feature](https://github.com/StereotypicalCat/gacha-event-tracker/issues/new?template=feature_request.yml)**
|
||||||
|
— including a game it does not cover yet, which is the most common ask by a distance.
|
||||||
|
|
||||||
|
Both are forms rather than a blank box, for one reason: nothing you mark, type or tick ever leaves
|
||||||
|
your browser, so there is no account and no server-side record for anyone to look up afterwards.
|
||||||
|
Whatever the report says is all there is. The bug form arrives with the footer's "event data last
|
||||||
|
refreshed" line already filled in, because a stale calendar and a genuinely wrong date look
|
||||||
|
identical from the outside and only that line tells them apart.
|
||||||
|
|
||||||
|
If a date looks wrong, **open the event and check its source link first.** This app rearranges what a
|
||||||
|
wiki published; it does not compile schedules. When the wiki says the same thing, the fix belongs
|
||||||
|
there — though it is still worth reporting, since a game with a consistently wrong source may need a
|
||||||
|
better one.
|
||||||
|
|
||||||
|
Blank issues are still open for anything that fits neither form. The templates themselves live in
|
||||||
|
[`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Covers |
|
| Document | Covers |
|
||||||
|
|||||||
@@ -4,6 +4,34 @@ import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
|||||||
|
|
||||||
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
|
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a reader takes a problem or an idea.
|
||||||
|
*
|
||||||
|
* `template=` names the file in `.github/ISSUE_TEMPLATE/`, so renaming one of
|
||||||
|
* those files breaks these links — GitHub falls back to the template chooser
|
||||||
|
* rather than erroring, which is a soft landing but not the intended one.
|
||||||
|
*/
|
||||||
|
export const BUG_URL = `${REPO_URL}/issues/new?template=bug_report.yml`;
|
||||||
|
export const FEATURE_URL = `${REPO_URL}/issues/new?template=feature_request.yml`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bug form, with the footer's own freshness line already filled in.
|
||||||
|
*
|
||||||
|
* A wrong end date and a stale calendar look identical to a reader, and eight of
|
||||||
|
* the sources cannot be fetched from CI at all — so "how old is this page's data"
|
||||||
|
* is the first thing anyone triaging a date report has to establish, and the one
|
||||||
|
* thing they cannot recover after the fact. Asking the reader to copy it works;
|
||||||
|
* carrying it for them works more often.
|
||||||
|
*
|
||||||
|
* `refreshed` must stay the `id` of the matching field in `bug_report.yml`.
|
||||||
|
* GitHub silently drops a parameter that names no field, so a drift here costs
|
||||||
|
* the prefill with no error anywhere — `test/issue-templates.test.tsx` pins it.
|
||||||
|
*/
|
||||||
|
export function bugReportUrl(refreshed: string | null): string {
|
||||||
|
if (refreshed === null) return BUG_URL;
|
||||||
|
return `${BUG_URL}&refreshed=${encodeURIComponent(refreshed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Who built this, and where to find them. */
|
/** Who built this, and where to find them. */
|
||||||
export const AUTHOR = {
|
export const AUTHOR = {
|
||||||
name: "Lucas Winther",
|
name: "Lucas Winther",
|
||||||
@@ -65,6 +93,13 @@ export function Colophon({
|
|||||||
const studios = [...new Set(games.map((g) => g.studio))];
|
const studios = [...new Set(games.map((g) => g.studio))];
|
||||||
const { refreshedAt, stale } = freshness(sources, now);
|
const { refreshedAt, stale } = freshness(sources, now);
|
||||||
|
|
||||||
|
// Built once so the sentence a reader reads and the value the bug form is
|
||||||
|
// prefilled with cannot drift apart.
|
||||||
|
const ago =
|
||||||
|
refreshedAt === null ? null : formatRemaining(now - Date.parse(refreshedAt));
|
||||||
|
const refreshedLine =
|
||||||
|
refreshedAt === null ? null : `${formatAbsolute(refreshedAt, true)} — ${ago} ago`;
|
||||||
|
|
||||||
const sites = [...new Map(sources.map((s) => {
|
const sites = [...new Map(sources.map((s) => {
|
||||||
const site = siteFor(s.url);
|
const site = siteFor(s.url);
|
||||||
return [site.name, site] as const;
|
return [site.name, site] as const;
|
||||||
@@ -95,7 +130,7 @@ export function Colophon({
|
|||||||
<time dateTime={refreshedAt} className="text-muted">
|
<time dateTime={refreshedAt} className="text-muted">
|
||||||
{formatAbsolute(refreshedAt, true)}
|
{formatAbsolute(refreshedAt, true)}
|
||||||
</time>
|
</time>
|
||||||
{` — ${formatRemaining(now - Date.parse(refreshedAt))} ago.`}
|
{` — ${ago} ago.`}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -220,6 +255,34 @@ export function Colophon({
|
|||||||
Source code
|
Source code
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Placed under the disclaimer that admits dates can be wrong, because that
|
||||||
|
paragraph is where a reader who has just found one is looking. The bug
|
||||||
|
link carries the freshness line above it, so a report arrives already
|
||||||
|
saying whether the calendar was current when it was wrong.
|
||||||
|
*/}
|
||||||
|
<p className="mt-2">
|
||||||
|
Something wrong, missing, or worth adding?{" "}
|
||||||
|
<a
|
||||||
|
href={bugReportUrl(refreshedLine)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className={LINK}
|
||||||
|
>
|
||||||
|
Report a problem
|
||||||
|
</a>{" "}
|
||||||
|
or{" "}
|
||||||
|
<a
|
||||||
|
href={FEATURE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className={LINK}
|
||||||
|
>
|
||||||
|
request a feature
|
||||||
|
</a>
|
||||||
|
{"."}
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import {
|
||||||
|
BUG_URL,
|
||||||
|
Colophon,
|
||||||
|
FEATURE_URL,
|
||||||
|
bugReportUrl,
|
||||||
|
} from "../src/client/components/Colophon.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The issue forms, and the app's links into them.
|
||||||
|
*
|
||||||
|
* These pin a seam nothing else can see: GitHub answers a malformed form with a
|
||||||
|
* rendered error only a visitor notices, and it drops a prefill parameter that
|
||||||
|
* names no field with no error at all. Both failures look exactly like a working
|
||||||
|
* link from here, so the only place they can be caught is a test that reads the
|
||||||
|
* YAML the links point at.
|
||||||
|
*
|
||||||
|
* Reads repo files, never build output — see AGENTS.md § Commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DIR = ".github/ISSUE_TEMPLATE";
|
||||||
|
|
||||||
|
type Field = {
|
||||||
|
type: string;
|
||||||
|
id?: string;
|
||||||
|
attributes?: { label?: string };
|
||||||
|
validations?: { required?: boolean };
|
||||||
|
};
|
||||||
|
type Form = { name?: string; description?: string; labels?: string[]; body?: Field[] };
|
||||||
|
|
||||||
|
async function form(file: string): Promise<Form> {
|
||||||
|
const text = await Bun.file(`${DIR}/${file}`).text();
|
||||||
|
return Bun.YAML.parse(text) as Form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `template=` filename a link points at, and its prefill parameters. */
|
||||||
|
function link(url: string): { template: string | null; params: URLSearchParams } {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const params = parsed.searchParams;
|
||||||
|
return { template: params.get("template"), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issue forms", () => {
|
||||||
|
for (const file of ["bug_report.yml", "feature_request.yml"]) {
|
||||||
|
test(`${file} is a well-formed issue form`, async () => {
|
||||||
|
const doc = await form(file);
|
||||||
|
|
||||||
|
expect(doc.name).toBeTruthy();
|
||||||
|
expect(doc.description).toBeTruthy();
|
||||||
|
expect(doc.body?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const field of doc.body ?? []) {
|
||||||
|
// GitHub rejects an unknown type outright.
|
||||||
|
expect([
|
||||||
|
"markdown",
|
||||||
|
"input",
|
||||||
|
"textarea",
|
||||||
|
"dropdown",
|
||||||
|
"checkboxes",
|
||||||
|
]).toContain(field.type);
|
||||||
|
|
||||||
|
// Every field that collects an answer needs a label to collect it under,
|
||||||
|
// and an id, or its answer cannot be prefilled or referenced.
|
||||||
|
if (field.type !== "markdown") {
|
||||||
|
expect(field.attributes?.label).toBeTruthy();
|
||||||
|
expect(field.id).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${file} has unique field ids`, async () => {
|
||||||
|
const ids = (await form(file)).body?.flatMap((f) => f.id ?? []) ?? [];
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${file} asks for something, but not for everything`, async () => {
|
||||||
|
const answered = (await form(file)).body?.filter((f) => f.type !== "markdown") ?? [];
|
||||||
|
const required = answered.filter((f) => f.validations?.required === true);
|
||||||
|
|
||||||
|
// At least one required field, or the form accepts an empty report.
|
||||||
|
expect(required.length).toBeGreaterThan(0);
|
||||||
|
// A form that demands every field is one a reader abandons — and this app
|
||||||
|
// has no account to look anything up in, so an abandoned report is the
|
||||||
|
// whole loss.
|
||||||
|
expect(required.length).toBeLessThan(answered.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${file} uses only labels a fresh repository already has`, async () => {
|
||||||
|
// A template naming a label that does not exist applies nothing, silently.
|
||||||
|
for (const label of (await form(file)).labels ?? []) {
|
||||||
|
expect(["bug", "enhancement"]).toContain(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("config.yml keeps the blank-issue escape hatch open", async () => {
|
||||||
|
const text = await Bun.file(`${DIR}/config.yml`).text();
|
||||||
|
expect(Bun.YAML.parse(text)).toEqual({ blank_issues_enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the app's links into them", () => {
|
||||||
|
test("each points at a template file that exists", async () => {
|
||||||
|
for (const url of [BUG_URL, FEATURE_URL]) {
|
||||||
|
const { template } = link(url);
|
||||||
|
expect(template).toBeTruthy();
|
||||||
|
expect(await Bun.file(`${DIR}/${template}`).exists()).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the prefilled freshness line names a real field on the bug form", async () => {
|
||||||
|
const ids = (await form("bug_report.yml")).body?.flatMap((f) => f.id ?? []) ?? [];
|
||||||
|
const { params } = link(bugReportUrl("15 Aug 2026, 04:12 — 2 days ago"));
|
||||||
|
|
||||||
|
for (const key of params.keys()) {
|
||||||
|
if (key === "template") continue;
|
||||||
|
// The failure this catches: renaming the field id leaves the link working
|
||||||
|
// and the prefill gone, with nothing anywhere to say so.
|
||||||
|
expect(ids).toContain(key);
|
||||||
|
}
|
||||||
|
expect(params.get("refreshed")).toBe("15 Aug 2026, 04:12 — 2 days ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every template link in the README names a template that exists", async () => {
|
||||||
|
// A `?template=` that names nothing lands the reader on the chooser instead,
|
||||||
|
// which looks close enough to working that nobody reports it.
|
||||||
|
const readme = await Bun.file("README.md").text();
|
||||||
|
const named = [...readme.matchAll(/issues\/new\?template=([\w.-]+)/g)].map(
|
||||||
|
(m) => m[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(named.length).toBeGreaterThan(0);
|
||||||
|
for (const template of named) {
|
||||||
|
expect(await Bun.file(`${DIR}/${template}`).exists()).toBe(true);
|
||||||
|
}
|
||||||
|
// Both forms are reachable from the README, not just the one.
|
||||||
|
expect(new Set(named)).toEqual(
|
||||||
|
new Set(["bug_report.yml", "feature_request.yml"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the footer offers both, and carries its own freshness line into the bug form", () => {
|
||||||
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<Colophon
|
||||||
|
sources={[
|
||||||
|
{
|
||||||
|
sourceId: "genshin-game8-events",
|
||||||
|
game: "genshin",
|
||||||
|
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||||
|
lastSuccessAt: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
|
||||||
|
eventCount: 9,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
now={NOW}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Report a problem");
|
||||||
|
expect(html).toContain("request a feature");
|
||||||
|
expect(html).toContain("template=feature_request.yml");
|
||||||
|
// The age the reader is being shown is the age the report will carry.
|
||||||
|
expect(html).toContain("3h 0m ago");
|
||||||
|
expect(html).toContain(encodeURIComponent("3h 0m ago"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unfetched feed prefills nothing rather than an empty value", () => {
|
||||||
|
// "" in the field reads as an answer the reader gave, and a report claiming
|
||||||
|
// the app never refreshed is worse than one that leaves the question open.
|
||||||
|
expect(bugReportUrl(null)).toBe(BUG_URL);
|
||||||
|
expect(link(bugReportUrl(null)).params.has("refreshed")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user