Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 363 additions & 15 deletions client/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"dev": "vite --host --port=4444",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "oxlint"
"lint": "oxlint",
"test": "vitest run"
},
"dependencies": {
"@codemirror/lang-javascript": "6.2.5",
Expand All @@ -35,6 +36,7 @@
"@vitejs/plugin-react-swc": "4.3.1",
"oxlint": "^1.73.0",
"typescript": "7.0.2",
"vite": "8.1.3"
"vite": "8.1.3",
"vitest": "4.1.10"
}
}
90 changes: 90 additions & 0 deletions client/src/challenges/evaluate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import { evaluateChecks, allPass } from "./evaluate";
import type { CheckSpec, SandboxEvent } from "./types";

const base = { label: "x", failMessage: "y" };

const checks: CheckSpec[] = [
{ ...base, kind: "requestMade", method: "GET", urlIncludes: "/futurama/characters" },
{ ...base, kind: "responseStatus", status: 200, urlIncludes: "/futurama/characters" },
{ ...base, kind: "consoleIncludes", pattern: "bender" },
{ ...base, kind: "consoleCount", min: 1 },
{ ...base, kind: "noUncaughtError" },
];

const passingEvents: SandboxEvent[] = [
{ type: "request", method: "GET", url: "http://localhost:5555/futurama/characters" },
{
type: "response",
method: "GET",
url: "http://localhost:5555/futurama/characters",
status: 200,
},
{ type: "console", level: "log", text: '[{"name":{"first":"Bender"}}]' },
];

describe("evaluateChecks", () => {
it("passes every check on a correct run", () => {
const results = evaluateChecks(checks, passingEvents);
expect(results.every((r) => r.pass)).toBe(true);
expect(allPass(results)).toBe(true);
});

it("fails everything on a run with zero events", () => {
const results = evaluateChecks(checks, []);
expect(results.filter((r) => r.pass).map((r) => r.spec.kind)).toEqual([
"noUncaughtError",
]);
expect(allPass(results)).toBe(false);
});

it("is order-independent: late async events still flip a check to passing", () => {
// Simulates an un-awaited .then(): console line arrives, THEN the response.
const early = passingEvents.slice(0, 1);
const midRun = evaluateChecks(checks, early);
expect(midRun.find((r) => r.spec.kind === "responseStatus")?.pass).toBe(false);

const reordered: SandboxEvent[] = [passingEvents[2], passingEvents[0], passingEvents[1]];
expect(allPass(evaluateChecks(checks, reordered))).toBe(true);
});

it("matches method and status filters exactly", () => {
const spec: CheckSpec[] = [
{ ...base, kind: "responseStatus", status: 201, method: "POST" },
];
const getOnly: SandboxEvent[] = [
{ type: "response", method: "GET", url: "/futurama/characters", status: 201 },
];
const posted: SandboxEvent[] = [
{ type: "response", method: "POST", url: "/futurama/characters", status: 201 },
];
expect(evaluateChecks(spec, getOnly)[0].pass).toBe(false);
expect(evaluateChecks(spec, posted)[0].pass).toBe(true);
});

it("counts requests for minCount", () => {
const spec: CheckSpec[] = [{ ...base, kind: "requestMade", minCount: 2 }];
const one: SandboxEvent[] = [{ type: "request", method: "GET", url: "/a" }];
const two: SandboxEvent[] = [...one, { type: "request", method: "POST", url: "/b" }];
expect(evaluateChecks(spec, one)[0].pass).toBe(false);
expect(evaluateChecks(spec, two)[0].pass).toBe(true);
});

it("consoleIncludes is case-insensitive substring match", () => {
const spec: CheckSpec[] = [{ ...base, kind: "consoleIncludes", pattern: "BENDER" }];
const events: SandboxEvent[] = [
{ type: "console", level: "log", text: "found bender rodriguez" },
];
expect(evaluateChecks(spec, events)[0].pass).toBe(true);
});

it("noUncaughtError fails once an uncaught event appears", () => {
const spec: CheckSpec[] = [{ ...base, kind: "noUncaughtError" }];
expect(evaluateChecks(spec, [])[0].pass).toBe(true);
expect(evaluateChecks(spec, [{ type: "uncaught" }])[0].pass).toBe(false);
});

it("allPass is false for an empty check list", () => {
expect(allPass([])).toBe(false);
});
});
60 changes: 60 additions & 0 deletions client/src/challenges/evaluate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CheckSpec, CheckResult, SandboxEvent } from "./types";

/**
* Evaluate a challenge's checks against the events a run has emitted so far.
*
* Pure function over the event array: the page re-runs it as events stream in
* (a late un-awaited .then() can still flip a check to passing), so no check may
* depend on evaluation order or timing — only on event contents.
*/
export function evaluateChecks(
checks: CheckSpec[],
events: SandboxEvent[]
): CheckResult[] {
return checks.map((spec) => ({ spec, pass: evaluateOne(spec, events) }));
}

export function allPass(results: CheckResult[]): boolean {
return results.length > 0 && results.every((r) => r.pass);
}

function evaluateOne(spec: CheckSpec, events: SandboxEvent[]): boolean {
switch (spec.kind) {
case "requestMade": {
const min = spec.minCount ?? 1;
const count = events.filter(
(e) =>
e.type === "request" &&
matchesMethod(e.method, spec.method) &&
matchesUrl(e.url, spec.urlIncludes)
).length;
return count >= min;
}
case "responseStatus":
return events.some(
(e) =>
e.type === "response" &&
e.status === spec.status &&
matchesMethod(e.method, spec.method) &&
matchesUrl(e.url, spec.urlIncludes)
);
case "consoleIncludes": {
const needle = spec.pattern.toLowerCase();
return events.some(
(e) => e.type === "console" && e.text.toLowerCase().includes(needle)
);
}
case "consoleCount":
return events.filter((e) => e.type === "console").length >= spec.min;
case "noUncaughtError":
return !events.some((e) => e.type === "uncaught");
}
}

function matchesMethod(method: string, wanted?: string): boolean {
return !wanted || method.toUpperCase() === wanted.toUpperCase();
}

function matchesUrl(url: string, includes?: string): boolean {
return !includes || url.toLowerCase().includes(includes.toLowerCase());
}
13 changes: 13 additions & 0 deletions client/src/challenges/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Track } from "./types";
import { restBasics } from "./rest-basics";

/** All available tracks, in display order. Adding a track = adding a data file
* and listing it here (same spirit as contributing a dataset). */
export const TRACKS: Track[] = [restBasics];

export const findTrack = (id: string): Track | undefined =>
TRACKS.find((t) => t.id === id);

/** Tracks that run against a given API — drives the details-page banner. */
export const tracksForApi = (apiLink: string): Track[] =>
TRACKS.filter((t) => t.apiLink === apiLink);
38 changes: 38 additions & 0 deletions client/src/challenges/playgroundBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { SandboxEvent } from "./types";
import type { PlaygroundRunEvent } from "../components/Playground/types";

const stringifyValue = (v: unknown): string => {
if (typeof v === "string") return v;
try {
return JSON.stringify(v) ?? String(v);
} catch {
return String(v);
}
};

/** Map a Playground run event to the evaluator's normalized shape. */
export const mapPlaygroundEvent = (ev: PlaygroundRunEvent): SandboxEvent | null => {
switch (ev.type) {
case "console":
return {
type: "console",
level: ev.level,
text: ev.values.map(stringifyValue).join(" "),
};
case "net":
if (ev.event.phase === "request")
return { type: "request", method: ev.event.method, url: ev.event.url };
if (ev.event.phase === "response")
return {
type: "response",
method: ev.event.method,
url: ev.event.url,
status: ev.event.status ?? 0,
};
return null;
case "uncaught":
return { type: "uncaught" };
default:
return null;
}
};
70 changes: 70 additions & 0 deletions client/src/challenges/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** Track progress persistence — localStorage only, no accounts, mirroring the
* Playground's own per-URL persistence. */

import type { Track } from "./types";

interface StoredProgress {
/** Completed challenge ids with completion timestamps (ms since epoch). */
completed: Record<string, number>;
}

const progressKey = (trackId: string) => `sampleapis:challenges:${trackId}`;
const codeKeyPrefix = (trackId: string) => `sampleapis:challenges:code:${trackId}:`;

export function getCompleted(trackId: string): Set<string> {
try {
const raw = localStorage.getItem(progressKey(trackId));
if (!raw) return new Set();
const parsed = JSON.parse(raw) as StoredProgress;
return new Set(Object.keys(parsed.completed ?? {}));
} catch {
return new Set();
}
}

export function markCompleted(trackId: string, challengeId: string): void {
const stored: StoredProgress = { completed: {} };
try {
const raw = localStorage.getItem(progressKey(trackId));
if (raw) stored.completed = (JSON.parse(raw) as StoredProgress).completed ?? {};
} catch {
/* corrupted entry — start fresh */
}
if (stored.completed[challengeId]) return;
stored.completed[challengeId] = Date.now();
localStorage.setItem(progressKey(trackId), JSON.stringify(stored));
}

export function resetProgress(trackId: string): void {
localStorage.removeItem(progressKey(trackId));
const prefix = codeKeyPrefix(trackId);
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith(prefix)) localStorage.removeItem(key);
}
}

export interface TrackStats {
done: number;
total: number;
firstIncomplete: number;
}

export function getTrackStats(track: Track, completed: Set<string>): TrackStats {
const total = track.challenges.length;
const done = track.challenges.filter((c) => completed.has(c.id)).length;
const idx = track.challenges.findIndex((c) => !completed.has(c.id));
const firstIncomplete = idx === -1 ? 1 : idx + 1;
return { done, total, firstIncomplete };
}

export function getCtaLabel(stats: TrackStats): string {
if (stats.done === 0) return "Start";
if (stats.done === stats.total) return "Start over";
return "Continue";
}

export function getResumeStep(stats: TrackStats): number {
if (stats.done === stats.total) return 1;
return stats.firstIncomplete;
}
29 changes: 29 additions & 0 deletions client/src/challenges/resolveChallenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { findTrack } from "./index";
import type { Challenge, Track } from "./types";

export interface ResolvedChallenge {
track: Track;
challenge: Challenge;
stepNum: number;
totalSteps: number;
}

export const resolveChallenge = (
trackId: string,
step: string
): ResolvedChallenge | undefined => {
const track = findTrack(trackId);
const stepNum = Number(step);
const validStep =
!!track &&
Number.isInteger(stepNum) &&
stepNum >= 1 &&
stepNum <= track.challenges.length;
if (!track || !validStep) return undefined;
return {
track,
challenge: track.challenges[stepNum - 1],
stepNum,
totalSteps: track.challenges.length,
};
};
Loading
Loading