feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast
Die Middleware liest jetzt zusaetzlich dv/dc/dos aus der Anfrage und legt daraus das Cookie tessera_desktop_client an (bereinigt per Muster, nur wenn alle drei Werte gueltig sind); desktop-client.ts liest es zurueck. Der Fehler-melden-Dialog fuellt daraus vier neue Nutzlastfelder (clientKind/clientOs/clientVersion/clientCommit), damit die API die Herkunft der Meldung ausweisen kann. Ohne das zweite Cookie (alter Client) bleibt es bei "Desktop-App (unbekannt)". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendBugReport } from './bug-report-api';
|
||||
|
||||
/**
|
||||
* bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der
|
||||
* Komponententest (`bug-report-button.test.tsx`) die FormData von
|
||||
* `sendBugReport` — die reinen Nutzlastfelder (insbesondere die vier neuen
|
||||
* Herkunftsfelder) gehoeren aber an die Funktion selbst, unabhaengig vom
|
||||
* Dialog. Drei Tests: Desktop-Nutzlast, Browser-Nutzlast, Netzwerk-/
|
||||
* HTTP-Fehler.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
const basePayload = {
|
||||
description: 'x',
|
||||
page: '/a',
|
||||
webVersion: 'v1',
|
||||
webChannel: 'beta',
|
||||
webCommit: 'c',
|
||||
userAgent: 'UA',
|
||||
viewport: '1x1',
|
||||
clientTime: 't',
|
||||
errors: ['e1', 'e2'],
|
||||
screenshot: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('sendBugReport (quick-260918-gza)', () => {
|
||||
it('Test 1: Desktop-Nutzlast — clientKind/clientOs/clientVersion/clientCommit im FormData, errors mehrfach, kein screenshot-Feld', async () => {
|
||||
const result = await sendBugReport({
|
||||
...basePayload,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/bug-reports')).toBe(true);
|
||||
expect(init.credentials).toBe('include');
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('clientKind')).toBe('desktop');
|
||||
expect(body.get('clientOs')).toBe('windows');
|
||||
expect(body.get('clientVersion')).toBe('1.2.0');
|
||||
expect(body.get('clientCommit')).toBe('a6d1a64');
|
||||
expect(body.getAll('errors')).toEqual(['e1', 'e2']);
|
||||
expect(body.has('screenshot')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 2: Browser-Nutzlast — die drei uebrigen Client-Felder sind vorhanden, aber leer (nicht weggelassen)', async () => {
|
||||
await sendBugReport({
|
||||
...basePayload,
|
||||
clientKind: 'browser',
|
||||
clientOs: '',
|
||||
clientVersion: '',
|
||||
clientCommit: '',
|
||||
});
|
||||
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.get('clientKind')).toBe('browser');
|
||||
expect(body.get('clientOs')).toBe('');
|
||||
expect(body.get('clientVersion')).toBe('');
|
||||
expect(body.get('clientCommit')).toBe('');
|
||||
});
|
||||
|
||||
it('Test 3: Netzwerkfehler -> { ok: false, status: 0 }; HTTP-Fehler -> { ok: false, status }', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(
|
||||
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).resolves.toEqual({ ok: false, status: 0 });
|
||||
|
||||
mockFetch.mockResolvedValueOnce(new Response('', { status: 429 }));
|
||||
await expect(
|
||||
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).resolves.toEqual({ ok: false, status: 429 });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@
|
||||
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
|
||||
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
|
||||
* Multipart-Grenze setzt der Browser selbst.
|
||||
*
|
||||
* Seit quick-260918-gza traegt die Nutzlast zusaetzlich vier Herkunftsfelder
|
||||
* (`clientKind`, `clientOs`, `clientVersion`, `clientCommit`) — im Browser
|
||||
* `clientKind=browser` mit leeren uebrigen Feldern, in der Desktop-App aus
|
||||
* `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts). Die API
|
||||
* leitet daraus das Betreff-Kuerzel und die Zeile `Herkunft:` ab.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -79,6 +85,10 @@ export interface BugReportPayload {
|
||||
clientTime: string;
|
||||
errors: string[];
|
||||
screenshot: Blob | null;
|
||||
clientKind: 'desktop' | 'browser';
|
||||
clientOs: string;
|
||||
clientVersion: string;
|
||||
clientCommit: string;
|
||||
}
|
||||
|
||||
export type BugReportResult = { ok: true } | { ok: false; status: number };
|
||||
@@ -93,6 +103,10 @@ export async function sendBugReport(p: BugReportPayload): Promise<BugReportResul
|
||||
body.append('userAgent', p.userAgent);
|
||||
body.append('viewport', p.viewport);
|
||||
body.append('clientTime', p.clientTime);
|
||||
body.append('clientKind', p.clientKind);
|
||||
body.append('clientOs', p.clientOs);
|
||||
body.append('clientVersion', p.clientVersion);
|
||||
body.append('clientCommit', p.clientCommit);
|
||||
for (const line of p.errors) body.append('errors', line);
|
||||
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DESKTOP_COOKIE_NAME, isDesktopClient, useIsDesktopClient } from './desktop-client';
|
||||
import {
|
||||
DESKTOP_CLIENT_COOKIE_NAME,
|
||||
DESKTOP_COOKIE_NAME,
|
||||
getDesktopClientInfo,
|
||||
isDesktopClient,
|
||||
parseDesktopClientCookie,
|
||||
useIsDesktopClient,
|
||||
} from './desktop-client';
|
||||
|
||||
/**
|
||||
* desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das
|
||||
* Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn
|
||||
* der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt.
|
||||
* Seit quick-260918-gza zusaetzlich `parseDesktopClientCookie()` /
|
||||
* `getDesktopClientInfo()` fuer das zweite Cookie `tessera_desktop_client`.
|
||||
*/
|
||||
function clearCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
document.cookie = `${DESKTOP_CLIENT_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,3 +60,50 @@ describe('useIsDesktopClient', () => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDesktopClientInfo / parseDesktopClientCookie (quick-260918-gza)', () => {
|
||||
it('kodierte Form (wie der Browser sie in document.cookie haelt)', () => {
|
||||
expect(
|
||||
parseDesktopClientCookie('tessera_desktop=1; tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows'),
|
||||
).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
|
||||
});
|
||||
|
||||
it('rohe Form (nicht kodiert)', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|windows')).toEqual({
|
||||
version: '1.2.0',
|
||||
commit: 'a6d1a64',
|
||||
os: 'windows',
|
||||
});
|
||||
});
|
||||
|
||||
it('leerer Commit bleibt leer', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0%7C%7Clinux')).toEqual({
|
||||
version: '1.2.0',
|
||||
commit: '',
|
||||
os: 'linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('ohne Cookie: null', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop=1')).toBeNull();
|
||||
});
|
||||
|
||||
it('unvollstaendige oder leere Pflichtfelder: null', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=abc')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|x')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=|a6d1a64|linux')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|')).toBeNull();
|
||||
});
|
||||
|
||||
it('getDesktopClientInfo() liest document.cookie', () => {
|
||||
expect(getDesktopClientInfo()).toBeNull();
|
||||
document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/';
|
||||
expect(getDesktopClientInfo()).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
|
||||
});
|
||||
|
||||
it('getDesktopClientInfo() ohne document: null', () => {
|
||||
vi.stubGlobal('document', undefined);
|
||||
expect(getDesktopClientInfo()).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,58 @@ import { useEffect, useState } from 'react';
|
||||
*/
|
||||
export const DESKTOP_COOKIE_NAME = 'tessera_desktop';
|
||||
|
||||
/**
|
||||
* Gegenstueck zu `buildDesktopClientCookieValue()` in der Middleware
|
||||
* (quick-260918-gza): dort werden `dv`/`dc`/`dos` mit `${dv}|${dc}|${dos}`
|
||||
* zusammengesetzt und von Next per `encodeURIComponent` serialisiert --
|
||||
* `|` wird `%7C` (gemessen). `document.cookie` liefert deshalb die
|
||||
* KODIERTE Form; `parseDesktopClientCookie()` dekodiert (in try/catch,
|
||||
* falls der Wert bereits roh vorliegt, z. B. direkt gesetzt im Test).
|
||||
*/
|
||||
export const DESKTOP_CLIENT_COOKIE_NAME = 'tessera_desktop_client';
|
||||
|
||||
export interface DesktopClientInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst den Wert des Cookies `tessera_desktop_client` aus einem rohen
|
||||
* `document.cookie`-String. `null` bei fehlendem Cookie oder einer Form,
|
||||
* die nicht genau drei durch `|` getrennte Teile mit nicht-leerer Version
|
||||
* und nicht-leerem Betriebssystem ergibt (Commit darf leer sein).
|
||||
*/
|
||||
export function parseDesktopClientCookie(cookieString: string): DesktopClientInfo | null {
|
||||
const entry = cookieString
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${DESKTOP_CLIENT_COOKIE_NAME}=`));
|
||||
if (!entry) return null;
|
||||
|
||||
const raw = entry.slice(DESKTOP_CLIENT_COOKIE_NAME.length + 1);
|
||||
let value = raw;
|
||||
try {
|
||||
value = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Rohwert (z. B. bereits dekodiert im Test) -- unveraendert weiterverwenden.
|
||||
}
|
||||
|
||||
const parts = value.split('|');
|
||||
if (parts.length !== 3) return null;
|
||||
const [version, commit, os] = parts;
|
||||
if (!version || !os) return null;
|
||||
return { version, commit, os };
|
||||
}
|
||||
|
||||
/** `null` ohne `document` (SSR) oder ohne gueltiges Cookie. */
|
||||
export function getDesktopClientInfo(): DesktopClientInfo | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return parseDesktopClientCookie(document.cookie);
|
||||
}
|
||||
|
||||
export function isDesktopClient(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user