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
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,17 @@ jobs:
if: ${{ !cancelled() }}
run: ./run-all-tests.sh

- name: Publish test history to Prometheus
# Two destinations, both optional: the Pushgateway holds the latest run
# per branch for the dashboard, `test_runs` accumulates so a trend has
# something to be a trend across. A missing secret disables that half and
# says so; neither can fail the build.
- name: Publish test history
if: ${{ !cancelled() }}
env:
PUSHGATEWAY_URL: ${{ secrets.PUSHGATEWAY_URL }}
PUSHGATEWAY_USERNAME: ${{ secrets.PUSHGATEWAY_USERNAME }}
PUSHGATEWAY_PASSWORD: ${{ secrets.PUSHGATEWAY_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm --filter @workspace/api-server run publish:test-metrics

- name: Link Grafana in the run summary
Expand Down
12 changes: 12 additions & 0 deletions artifacts/ai-testing-academy/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,15 @@ export function useAuth(): AuthContextValue {
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

/**
* The signed-in reader, for a consumer that works either way.
*
* `ProgressContext` is the case this exists for: it tracks progress for
* everyone and only syncs it for someone with an account, so being mounted
* without an `AuthProvider` — which is how most component tests mount it — is
* an ordinary state and not the programming error `useAuth` reports.
*/
export function useOptionalAuth(): AuthContextValue | null {
return useContext(AuthContext);
}
138 changes: 98 additions & 40 deletions artifacts/ai-testing-academy/src/context/ProgressContext.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { readValidated, writeValidated } from '../lib/storage';

export type ToolId = 'resume' | 'interview' | 'practice';

interface AcademyProgress {
resumeStarted: boolean;
resumeCompleted: boolean;
interviewStarted: boolean;
interviewAnswers: number;
interviewCompleted: boolean;
practiceCompleted: string[];
lecturesViewed: string[];
lastTool: ToolId | null;
}
import { useOptionalAuth } from './AuthContext';
import {
fetchProgress,
pushProgress,
validateProgress,
MAX_PROGRESS_IDS,
type AcademyProgress,
type ToolId,
} from '../lib/progressApi';

export type { ToolId };

interface ProgressContextValue extends AcademyProgress {
startTool: (tool: ToolId) => void;
Expand All @@ -36,38 +34,47 @@ const EMPTY_PROGRESS: AcademyProgress = {
};

function loadProgress(): AcademyProgress {
// A stored value and a response body are the same kind of thing here — both
// arrive from outside and both land in the state the site renders from — so
// both go through the one validator.
return (
readValidated<AcademyProgress>(localStorage, STORAGE_KEY, validateProgress) ?? EMPTY_PROGRESS
);
}

/** Order-insensitive on the two set columns, because the server's union is. */
function sameProgress(a: AcademyProgress, b: AcademyProgress): boolean {
const sameIds = (left: string[], right: string[]) =>
left.length === right.length &&
[...left].sort().join('\u0000') === [...right].sort().join('\u0000');
return (
readValidated<AcademyProgress>(localStorage, STORAGE_KEY, raw => {
const parsed = (raw ?? {}) as Partial<AcademyProgress>;
return {
resumeStarted: parsed.resumeStarted === true,
resumeCompleted: parsed.resumeCompleted === true,
interviewStarted: parsed.interviewStarted === true,
interviewAnswers:
Number.isSafeInteger(parsed.interviewAnswers) && Number(parsed.interviewAnswers) >= 0
? Number(parsed.interviewAnswers)
: 0,
interviewCompleted: parsed.interviewCompleted === true,
practiceCompleted: Array.isArray(parsed.practiceCompleted)
? parsed.practiceCompleted
.filter((id): id is string => typeof id === 'string')
.slice(0, 500)
: [],
lecturesViewed: Array.isArray(parsed.lecturesViewed)
? parsed.lecturesViewed.filter((id): id is string => typeof id === 'string').slice(0, 500)
: [],
lastTool: ['resume', 'interview', 'practice'].includes(parsed.lastTool || '')
? (parsed.lastTool as ToolId)
: null,
};
}) ?? EMPTY_PROGRESS
a.resumeStarted === b.resumeStarted &&
a.resumeCompleted === b.resumeCompleted &&
a.interviewStarted === b.interviewStarted &&
a.interviewAnswers === b.interviewAnswers &&
a.interviewCompleted === b.interviewCompleted &&
a.lastTool === b.lastTool &&
sameIds(a.practiceCompleted, b.practiceCompleted) &&
sameIds(a.lecturesViewed, b.lecturesViewed)
);
}

/**
* How long a burst of local changes is allowed to settle before it is synced.
*
* Answering an interview question updates progress on every answer; without
* this, so does a request.
*/
const SYNC_DELAY_MS = 1_000;

const ProgressContext = createContext<ProgressContextValue | null>(null);

export function ProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgress] = useState<AcademyProgress>(loadProgress);
// Optional on purpose: progress is tracked for everyone and synced only for
// someone with an account, so a tree without an AuthProvider is a normal one.
const user = useOptionalAuth()?.user ?? null;
const signedIn = user !== null;

const update = useCallback((fn: (current: AcademyProgress) => AcademyProgress) => {
setProgress(current => {
Expand All @@ -77,6 +84,57 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
});
}, []);

/**
* Take a copy that came back from the server, if it says anything new.
*
* The guard is what stops the sync below from running forever: adopting the
* server's answer sets state, setting state re-runs the effect, and the next
* push returns the same union. Comparing before adopting makes the second
* round a no-op instead of the next lap.
*/
const adopt = useCallback((remote: AcademyProgress | null) => {
if (!remote) return;
setProgress(current => {
if (sameProgress(current, remote)) return current;
writeValidated(localStorage, STORAGE_KEY, remote);
return remote;
});
Comment on lines +98 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Stale refresh erases progress 🐞 Bug ≡ Correctness

adopt replaces current state and localStorage with every differing response, so a visibility GET
started before a local update can return the older server copy and erase that unsynced update. The
same replacement is also unsafe for delayed PUT responses because no request generation or
state-version check is applied.
Agent Prompt
## Issue description
Asynchronous GET/PUT responses can be older than the current local state, but `adopt` replaces that state outright. Preserve monotonic local achievements when adopting remote data and reject responses from stale request/state generations.

## Issue Context
The visibility GET is independent of progress changes, so its controller is not aborted when the user records new progress.

## Fix Focus Areas
- artifacts/ai-testing-academy/src/context/ProgressContext.tsx[95-136]
- artifacts/ai-testing-academy/src/lib/progressApi.ts[63-101]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}, []);

// Merge this device's copy into the stored one, and adopt the union. Runs on
// sign-in and after every local change that settles, and it is the only write
// path: the response is the merge, so one call both saves and refreshes.
useEffect(() => {
if (!signedIn) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
void pushProgress(progress, controller.signal).then(adopt);
}, SYNC_DELAY_MS);
Comment on lines +107 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Progress crosses user accounts 🐞 Bug ≡ Correctness

ProgressProvider keeps one global localStorage/state copy and pushes it whenever any user becomes
signed in, so signing out user A and signing in user B merges A's progress into B's server record.
Neither the auth transition nor the sync effect resets or namespaces progress by identity.
Agent Prompt
## Issue description
Progress retained from one account is pushed under the next account's authenticated session. Partition or reset progress when the authenticated identity changes while preserving a deliberate anonymous-to-first-account merge.

## Issue Context
The provider remains mounted through logout/login and currently tracks only `user !== null`, not which user is active.

## Fix Focus Areas
- artifacts/ai-testing-academy/src/context/ProgressContext.tsx[24-42]
- artifacts/ai-testing-academy/src/context/ProgressContext.tsx[72-117]
- artifacts/ai-testing-academy/src/context/AuthContext.tsx[96-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [signedIn, progress, adopt]);

// The other half of "follows you between devices": progress made on a phone
// is already stored, and this is the moment the laptop is worth telling. A
// read rather than a merge, because nothing changed here while the tab was
// in the background.
useEffect(() => {
if (!signedIn) return;
const controller = new AbortController();
const refresh = () => {
if (document.visibilityState === 'visible') {
void fetchProgress(controller.signal).then(adopt);
}
};
document.addEventListener('visibilitychange', refresh);
return () => {
document.removeEventListener('visibilitychange', refresh);
controller.abort();
};
}, [signedIn, adopt]);

const startTool = useCallback(
(tool: ToolId) =>
update(current => ({
Expand Down Expand Up @@ -128,7 +186,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
lastTool: 'practice',
practiceCompleted: current.practiceCompleted.includes(id)
? current.practiceCompleted
: [...current.practiceCompleted, id],
: [...current.practiceCompleted, id].slice(-MAX_PROGRESS_IDS),
})),
[update],
);
Expand All @@ -139,7 +197,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
...current,
lecturesViewed: current.lecturesViewed.includes(id)
? current.lecturesViewed
: [...current.lecturesViewed, id],
: [...current.lecturesViewed, id].slice(-MAX_PROGRESS_IDS),
})),
[update],
);
Expand Down
102 changes: 102 additions & 0 deletions artifacts/ai-testing-academy/src/lib/progressApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Progress, on the server, for a reader who is signed in.
*
* `localStorage` stays the thing the page renders from. This is the copy that
* survives a cleared browser and follows someone from their laptop to their
* phone, and every call here is allowed to fail: a signed-out reader, a
* deployment with no database, an offline moment — all of them mean "no remote
* copy right now", which is the same answer the site has always worked with.
*
* The write is a merge rather than a replace, and the server returns the union.
* That is what makes two devices safe: whichever one syncs second does not
* discard what the first one recorded. See `database.merge_progress`.
*/

export type ToolId = 'resume' | 'interview' | 'practice';

export interface AcademyProgress {
resumeStarted: boolean;
resumeCompleted: boolean;
interviewStarted: boolean;
interviewAnswers: number;
interviewCompleted: boolean;
practiceCompleted: string[];
lecturesViewed: string[];
lastTool: ToolId | null;
}

/** The same bound the API and the database apply, applied before the request. */
export const MAX_PROGRESS_IDS = 500;

const TOOLS: ToolId[] = ['resume', 'interview', 'practice'];

/**
* A response is input, exactly as a stored value is.
*
* This one arrives from our own API, but it lands in React state that the whole
* site renders from, and the shape is the only thing standing between a bad
* response and a render crash. Anything unrecognised reads as absent, so the
* caller keeps what it already had.
*/
export function validateProgress(value: unknown): AcademyProgress | null {
if (typeof value !== 'object' || value === null) return null;
const parsed = value as Partial<AcademyProgress>;
const ids = (list: unknown): string[] =>
Array.isArray(list)
? list.filter((id): id is string => typeof id === 'string').slice(0, MAX_PROGRESS_IDS)
: [];
return {
resumeStarted: parsed.resumeStarted === true,
resumeCompleted: parsed.resumeCompleted === true,
interviewStarted: parsed.interviewStarted === true,
interviewAnswers:
Number.isSafeInteger(parsed.interviewAnswers) && Number(parsed.interviewAnswers) >= 0
? Number(parsed.interviewAnswers)
: 0,
interviewCompleted: parsed.interviewCompleted === true,
practiceCompleted: ids(parsed.practiceCompleted),
lecturesViewed: ids(parsed.lecturesViewed),
lastTool: TOOLS.includes(parsed.lastTool as ToolId) ? (parsed.lastTool as ToolId) : null,
};
}

async function progressFrom(response: Response): Promise<AcademyProgress | null> {
if (!response.ok) return null;
const body = (await response.json()) as { progress?: unknown };
return validateProgress(body?.progress);
}

/** The stored copy, or null when there is not one to be had. */
export async function fetchProgress(signal?: AbortSignal): Promise<AcademyProgress | null> {
try {
return await progressFrom(
await fetch('/api/progress', { credentials: 'include', cache: 'no-store', signal }),
);
} catch {
return null;
}
}

/** Merges this device's copy into the stored one and returns the union. */
export async function pushProgress(
progress: AcademyProgress,
signal?: AbortSignal,
): Promise<AcademyProgress | null> {
try {
return await progressFrom(
await fetch('/api/progress', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...progress,
practiceCompleted: progress.practiceCompleted.slice(0, MAX_PROGRESS_IDS),
lecturesViewed: progress.lecturesViewed.slice(0, MAX_PROGRESS_IDS),
}),
signal,
}),
);
} catch {
return null;
}
}
33 changes: 33 additions & 0 deletions lib/api-client-react/src/generated/api.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,39 @@ export interface EntitlementResponse {
purchasedAt: string | null;
}

/**
* @nullable
*/
export type ProgressLastTool = (typeof ProgressLastTool)[keyof typeof ProgressLastTool] | null;

export const ProgressLastTool = {
resume: 'resume',
interview: 'interview',
practice: 'practice',
} as const;

export interface Progress {
resumeStarted: boolean;
resumeCompleted: boolean;
interviewStarted: boolean;
/**
* @minimum 0
* @maximum 10000
*/
interviewAnswers: number;
interviewCompleted: boolean;
/** @maxItems 500 */
practiceCompleted: string[];
/** @maxItems 500 */
lecturesViewed: string[];
/** @nullable */
lastTool: ProgressLastTool;
}

export interface ProgressResponse {
progress: Progress;
}

export interface ContentError {
error: string;
}
Expand Down
Loading
Loading