mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add BYOPacket analyzer. Closes #98.
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
inspectRawPacketWithOptions,
|
||||
type PacketByteField,
|
||||
} from '../utils/rawPacketInspector';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface RawPacketDetailModalProps {
|
||||
@@ -16,6 +18,11 @@ interface RawPacketDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
interface FieldPaletteEntry {
|
||||
box: string;
|
||||
boxActive: string;
|
||||
@@ -500,37 +507,146 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
[channels]
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
|
||||
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
||||
[decoderOptions, packet]
|
||||
);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
|
||||
const packetDisplayFields = useMemo(
|
||||
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(
|
||||
() => (inspection ? buildDisplayFields(inspection) : []),
|
||||
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
||||
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||
const packetContext = useMemo(
|
||||
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
|
||||
() => getPacketContext(packet, inspection, groupTextCandidates),
|
||||
[groupTextCandidates, inspection, packet]
|
||||
);
|
||||
const packetIsDecrypted = useMemo(
|
||||
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
|
||||
() => packetShowsDecryptedState(packet, inspection),
|
||||
[inspection, packet]
|
||||
);
|
||||
|
||||
if (!packet || !inspection) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(packet.data);
|
||||
toast.success('Packet hex copied!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
if (!packet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -543,101 +659,7 @@ export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDet
|
||||
Detailed byte and field breakdown for the selected raw packet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RawPacketInspectionPanel packet={packet} channels={channels} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketDetailModal } from './RawPacketDetailModal';
|
||||
import { RawPacketDetailModal, RawPacketInspectionPanel } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
@@ -371,6 +373,36 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePacketHex(input: string): string {
|
||||
return input.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function validatePacketHex(input: string): string | null {
|
||||
if (!input) {
|
||||
return 'Paste a packet hex string to analyze.';
|
||||
}
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return 'Packet hex may only contain 0-9 and A-F characters.';
|
||||
}
|
||||
if (input.length % 2 !== 0) {
|
||||
return 'Packet hex must contain an even number of characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPastedRawPacket(packetHex: string): RawPacket {
|
||||
return {
|
||||
id: -1,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
data: packetHex,
|
||||
payload_type: 'Unknown',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function RawPacketFeedView({
|
||||
packets,
|
||||
rawPacketStatsSession,
|
||||
@@ -385,6 +417,8 @@ export function RawPacketFeedView({
|
||||
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
@@ -418,6 +452,26 @@ export function RawPacketFeedView({
|
||||
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.newestNeighbors]
|
||||
);
|
||||
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
||||
const packetInputError = useMemo(
|
||||
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
||||
[normalizedPacketInput]
|
||||
);
|
||||
const analyzedPacket = useMemo(
|
||||
() =>
|
||||
normalizedPacketInput.length > 0 && packetInputError === null
|
||||
? buildPastedRawPacket(normalizedPacketInput)
|
||||
: null,
|
||||
[normalizedPacketInput, packetInputError]
|
||||
);
|
||||
|
||||
const handleAnalyzeModalChange = (isOpen: boolean) => {
|
||||
setAnalyzeModalOpen(isOpen);
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
setPacketInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -428,15 +482,26 @@ export function RawPacketFeedView({
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
@@ -604,6 +669,40 @@ export function RawPacketFeedView({
|
||||
channels={channels}
|
||||
onClose={() => setSelectedPacket(null)}
|
||||
/>
|
||||
|
||||
<Dialog open={analyzeModalOpen} onOpenChange={handleAnalyzeModalChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Analyze Packet</DialogTitle>
|
||||
<DialogDescription>Paste and inspect a raw packet hex string.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
||||
Packet Hex
|
||||
</label>
|
||||
<textarea
|
||||
id="raw-packet-input"
|
||||
value={packetInput}
|
||||
onChange={(event) => setPacketInput(event.target.value)}
|
||||
placeholder="Paste raw packet hex here..."
|
||||
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{packetInputError ? (
|
||||
<div className="text-sm text-destructive">{packetInputError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{analyzedPacket ? (
|
||||
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Paste a packet above and click Analyze to inspect it.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,7 +246,12 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setAdvancedOpen((prev) => !prev)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const BOT_CHANNEL: Channel = {
|
||||
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
|
||||
name: '#bot',
|
||||
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
|
||||
};
|
||||
|
||||
describe('RawPacketDetailModal', () => {
|
||||
it('copies the full packet hex to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
|
||||
});
|
||||
|
||||
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('analyzes a pasted raw packet without adding it to the live feed', () => {
|
||||
renderView({ channels: [TEST_CHANNEL] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Hex'), {
|
||||
target: { value: GROUP_TEXT_PACKET_HEX },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stats by default on desktop', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
|
||||
Reference in New Issue
Block a user