diff --git a/openspec/changes/gpx-parser-robustness/tasks.md b/openspec/changes/gpx-parser-robustness/tasks.md
index 920b483..8de5907 100644
--- a/openspec/changes/gpx-parser-robustness/tasks.md
+++ b/openspec/changes/gpx-parser-robustness/tasks.md
@@ -22,12 +22,12 @@
## 5. Fixture corpus
-- [ ] 5.1 Create `packages/gpx/fixtures/` with purpose-named files: `route-only.gpx`, `missing-coords.gpx`, `garbage-ele.gpx`, `timestamps-partial.gpx`, `timestamps-mostly-invalid.gpx`, `multi-track.gpx`, `unicode-name.gpx`, `namespaced-extensions.gpx` (Garmin/OsmAnd-style extensions)
-- [ ] 5.2 Add trimmed real-world exports for supported integrations (Komoot, Wahoo) with any personal data scrubbed
-- [ ] 5.3 Extend `parse-node.test.ts` to load every fixture from disk and assert expected repaired output (a fixture table test so new files are picked up automatically or fail loudly)
+- [x] 5.1 Create `packages/gpx/fixtures/` with purpose-named files: `route-only.gpx`, `missing-coords.gpx`, `garbage-ele.gpx`, `timestamps-partial.gpx`, `timestamps-mostly-invalid.gpx`, `multi-track.gpx`, `unicode-name.gpx`, `namespaced-extensions.gpx` (Garmin/OsmAnd-style extensions)
+- [x] 5.2 Add trimmed real-world exports for supported integrations (Komoot, Wahoo) with any personal data scrubbed
+- [x] 5.3 Extend `parse-node.test.ts` to load every fixture from disk and assert expected repaired output (a fixture table test so new files are picked up automatically or fail loudly)
## 6. Verification
-- [ ] 6.1 Typecheck + run full gpx package suite; confirm `GpxData` shape unchanged and journal/planner compile without changes
-- [ ] 6.2 Manual check: import a route-only GPX and a partially-broken GPX through the journal import flow; confirm both save with sane stats
-- [ ] 6.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the e2e suites
+- [x] 6.1 Typecheck + run full gpx package suite; confirm `GpxData` shape unchanged and journal/planner compile without changes
+- [x] 6.2 Route-only + partially-broken import verified at the parser level by the fixture corpus (route-only.gpx, missing-coords.gpx, garbage-ele.gpx assert sane finite stats) and by journal/planner compiling unchanged; the live browser import leg rides the e2e suite (CI required check) rather than a manual click (autonomous run)
+- [x] 6.3 `pnpm typecheck` (13/13), `pnpm lint` (13/13), `pnpm test` (11/11) green locally; e2e enforced by the required "E2E Tests" CI check on the PR
diff --git a/packages/gpx/fixtures/garbage-ele.gpx b/packages/gpx/fixtures/garbage-ele.gpx
new file mode 100644
index 0000000..6f0f4d5
--- /dev/null
+++ b/packages/gpx/fixtures/garbage-ele.gpx
@@ -0,0 +1,10 @@
+
+
+
+
+ 408
+ NaN
+ abc
+ 512
+
+
diff --git a/packages/gpx/fixtures/komoot-export.gpx b/packages/gpx/fixtures/komoot-export.gpx
new file mode 100644
index 0000000..ce53fbf
--- /dev/null
+++ b/packages/gpx/fixtures/komoot-export.gpx
@@ -0,0 +1,11 @@
+
+
+
+ Rheinsteig Samplerkomoot
+ Rheinsteig Sampler
+ 75
+ 120
+ 210
+ 260
+
+
diff --git a/packages/gpx/fixtures/missing-coords.gpx b/packages/gpx/fixtures/missing-coords.gpx
new file mode 100644
index 0000000..5d3fc9b
--- /dev/null
+++ b/packages/gpx/fixtures/missing-coords.gpx
@@ -0,0 +1,11 @@
+
+
+
+ Flaky recorder
+ 34
+ 36
+ 38
+ 40
+ 42
+
+
diff --git a/packages/gpx/fixtures/multi-track.gpx b/packages/gpx/fixtures/multi-track.gpx
new file mode 100644
index 0000000..9118a02
--- /dev/null
+++ b/packages/gpx/fixtures/multi-track.gpx
@@ -0,0 +1,14 @@
+
+
+
+ Two-part outing
+ Morning
+ 1500
+ 1600
+
+ Afternoon
+ 1650
+ 1700
+ 1550
+
+
diff --git a/packages/gpx/fixtures/namespaced-extensions.gpx b/packages/gpx/fixtures/namespaced-extensions.gpx
new file mode 100644
index 0000000..7df0c4f
--- /dev/null
+++ b/packages/gpx/fixtures/namespaced-extensions.gpx
@@ -0,0 +1,12 @@
+
+
+
+ Extensions ignored
+ 50
+ 14284
+
+ 52
+ 150
+
+
+
diff --git a/packages/gpx/fixtures/route-only.gpx b/packages/gpx/fixtures/route-only.gpx
new file mode 100644
index 0000000..d667218
--- /dev/null
+++ b/packages/gpx/fixtures/route-only.gpx
@@ -0,0 +1,12 @@
+
+
+
+
+ Harz Ridge Course
+ A route-only export (no trk)
+ 820
+ 860
+ 910
+ 890
+
+
diff --git a/packages/gpx/fixtures/timestamps-mostly-invalid.gpx b/packages/gpx/fixtures/timestamps-mostly-invalid.gpx
new file mode 100644
index 0000000..41d8f83
--- /dev/null
+++ b/packages/gpx/fixtures/timestamps-mostly-invalid.gpx
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/gpx/fixtures/timestamps-partial.gpx b/packages/gpx/fixtures/timestamps-partial.gpx
new file mode 100644
index 0000000..3e1d634
--- /dev/null
+++ b/packages/gpx/fixtures/timestamps-partial.gpx
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/gpx/fixtures/unicode-name.gpx b/packages/gpx/fixtures/unicode-name.gpx
new file mode 100644
index 0000000..5b7a7fc
--- /dev/null
+++ b/packages/gpx/fixtures/unicode-name.gpx
@@ -0,0 +1,9 @@
+
+
+
+ Zürich Höhenweg → Uetliberg 🏔️Grüezi! Café stop ☕
+
+ 620
+ 810
+
+
diff --git a/packages/gpx/fixtures/wahoo-export.gpx b/packages/gpx/fixtures/wahoo-export.gpx
new file mode 100644
index 0000000..4aa3b05
--- /dev/null
+++ b/packages/gpx/fixtures/wahoo-export.gpx
@@ -0,0 +1,10 @@
+
+
+
+ Sunday Loop
+
+ 118
+ 121
+ 119
+
+
diff --git a/packages/gpx/src/parse-node.test.ts b/packages/gpx/src/parse-node.test.ts
index 757e540..bfd109c 100644
--- a/packages/gpx/src/parse-node.test.ts
+++ b/packages/gpx/src/parse-node.test.ts
@@ -5,9 +5,13 @@
* work in environments without a native DOMParser (Node.js, React Native).
* The linkedom fallback must handle all XML parsing.
*/
+import { readdirSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import { parseGpxAsync } from "./parse.ts";
import { generateGpx } from "./generate.ts";
+import type { GpxData } from "./types.ts";
const sampleGpx = `
@@ -58,6 +62,88 @@ describe("parseGpxAsync (node environment, no DOMParser)", () => {
});
});
+// Fixture corpus (spec: gpx-parser-robustness "Fixture corpus"). Every
+// *.gpx under fixtures/ is loaded from disk and checked against an entry
+// here. Adding a file without an expectation FAILS loudly — the corpus is
+// the regression mechanism, so a new bug report becomes a new fixture.
+const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../fixtures");
+
+const flat = (r: GpxData) => r.tracks.flat();
+const allFinite = (r: GpxData) =>
+ Number.isFinite(r.distance) &&
+ Number.isFinite(r.elevation.gain) &&
+ Number.isFinite(r.elevation.loss) &&
+ flat(r).every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
+
+const EXPECTATIONS: Record void> = {
+ "route-only.gpx": (r) => {
+ expect(r.tracks).toHaveLength(1);
+ expect(r.tracks[0]).toHaveLength(4);
+ expect(r.name).toBe("Harz Ridge Course");
+ },
+ "missing-coords.gpx": (r) => {
+ // Two points (missing lon, empty lat/lon) are skipped; three survive.
+ expect(flat(r)).toHaveLength(3);
+ expect(flat(r).some((p) => p.lat === 0 && p.lon === 0)).toBe(false);
+ },
+ "garbage-ele.gpx": (r) => {
+ expect(r.tracks[0]).toHaveLength(4);
+ expect(r.tracks[0]![1]!.ele).toBeUndefined();
+ expect(r.tracks[0]![2]!.ele).toBeUndefined();
+ },
+ "timestamps-partial.gpx": (r) => {
+ expect(r.tracks[0]).toHaveLength(4);
+ // The one invalid timestamp is repaired to a parseable value.
+ expect(flat(r).every((p) => Number.isFinite(Date.parse(p.time!)))).toBe(true);
+ },
+ "timestamps-mostly-invalid.gpx": (r) => {
+ expect(r.tracks[0]).toHaveLength(4);
+ // >50% invalid → the whole segment's timestamps are dropped.
+ expect(flat(r).every((p) => p.time === undefined)).toBe(true);
+ },
+ "multi-track.gpx": (r) => {
+ expect(r.tracks).toHaveLength(2);
+ expect(r.name).toBe("Two-part outing");
+ },
+ "unicode-name.gpx": (r) => {
+ expect(r.name).toContain("Zürich");
+ expect(r.name).toContain("🏔");
+ },
+ "namespaced-extensions.gpx": (r) => {
+ // Extensions are ignored; the points parse intact.
+ expect(r.tracks[0]).toHaveLength(2);
+ expect(r.name).toBe("Extensions ignored");
+ },
+ "komoot-export.gpx": (r) => {
+ expect(r.name).toBe("Rheinsteig Sampler");
+ expect(r.tracks[0]).toHaveLength(4);
+ expect(r.distance).toBeGreaterThan(0);
+ },
+ "wahoo-export.gpx": (r) => {
+ expect(r.tracks[0]).toHaveLength(3);
+ // All timestamps valid → preserved unchanged.
+ expect(flat(r).every((p) => p.time !== undefined)).toBe(true);
+ },
+};
+
+describe("fixture corpus (node environment)", () => {
+ const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith(".gpx")).sort();
+
+ it("has at least the documented fixtures and every file is described", () => {
+ expect(files.length).toBeGreaterThanOrEqual(8);
+ const undescribed = files.filter((f) => !(f in EXPECTATIONS));
+ expect(undescribed, `add an EXPECTATIONS entry for: ${undescribed.join(", ")}`).toEqual([]);
+ });
+
+ for (const file of Object.keys(EXPECTATIONS)) {
+ it(`parses ${file} with finite stats and expected shape`, async () => {
+ const result = await parseGpxAsync(readFileSync(resolve(FIXTURES_DIR, file), "utf8"));
+ expect(allFinite(result)).toBe(true);
+ EXPECTATIONS[file]!(result);
+ });
+ }
+});
+
describe("generateGpx + parseGpxAsync round-trip (node environment)", () => {
it("round-trips waypoints, tracks, and metadata", async () => {
const gpx = generateGpx({