feat(relay): render dd() responses properly (#29)

* feat(relay): render `dd()` responses correctly

* wiki(relay): `dd` responses

* test: fix failing test after changing test id

* style: apply TS style fixes

* style: apply php style fixes

* chore: fix types

* build: always run php and js test on PR changes

* test(relay): simplify test

* build: use proper cache key for e2e job

* feat(relay): more assertive dd parser

* build: fix package.json indentation

* build: properly build for `dev` mode

* test: correctly install current branch artifacts for E2E

* test: fail fast for PW tests

* build: don't run php and js tests twice on PRs

* test: PW fixes

* test: correctly build artifacts from branch for E2E

* chore: fix naming

* fix(relay): properly parse some type of closures in `dd`

* test: enable parallel runs for PW

* test(relay): simplify `dd` E2E test
This commit is contained in:
Mazen Touati
2026-01-04 15:57:57 +01:00
committed by GitHub
parent 64ef46a8a4
commit e3b3370ebe
53 changed files with 5698 additions and 142 deletions

View File

@@ -3,7 +3,7 @@ on:
push:
branches: [ base ]
pull_request:
branches: [ base ]
branches: [ base ]
jobs:
test:
timeout-minutes: 5
@@ -44,7 +44,7 @@ jobs:
uses: actions/cache@v3
with:
path: vendor
key: composer-cache-${{ hashFiles('composer.lock') }}
key: composer-cache-${{ steps.branch.outputs.branch_name }}
restore-keys: |
composer-8.2-L12
composer-8.2

View File

@@ -1,7 +1,15 @@
name: run-js-tests
on:
pull_request:
paths:
- '**.ts'
- '**.js'
- '.github/workflows/js-tests.yml'
- 'package.json'
- 'package-lock.json'
push:
branches: [ base ]
paths:
- '**.ts'
- '**.js'

View File

@@ -1,13 +1,21 @@
name: run-php-tests
on:
push:
pull_request:
paths:
- '**.php'
- '.github/workflows/php-tests.yml'
- '../../tests/phpunit.xml.dist'
- 'composer.json'
- 'composer.lock'
push:
branches: [ base ]
paths:
- '**.php'
- '.github/workflows/php-tests.yml'
- 'tests/phpunit.xml.dist'
- 'composer.json'
- 'composer.lock'
jobs:
test:

View File

@@ -28,5 +28,5 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run tests
- name: Run Type Checks
run: npm run type:check

View File

@@ -1,84 +1,84 @@
{
"private": true,
"type": "module",
"scripts": {
"style:check": "eslint --config tools/eslint/.eslintrc.cjs --ignore-path tools/eslint/.eslintignore resources/js --ext .js,.ts,.vue",
"style:fix": "npm run style:check -- --fix",
"type:check": "vue-tsc --noEmit --project ./resources/tsconfig.json",
"vite": "vite --config vite.config.js",
"build": "npm run type:check && npm run vite -- build && cp -a ./resources/dist-static/. ./resources/dist",
"build:dev": "npm run vite -- build --watch --mode=development",
"dev": "npm run vite -- dev",
"test": "vitest --config resources/js/tests/vitest.config.js",
"test:ui": "npm run test -- --ui",
"test:run": "npm run test -- run",
"test:coverage": "npm run test:run --coverage",
"test:watch": "npm run test -- --watch",
"test:snapshot": "npm run test:run --reporter=verbose --update",
"test:e2e": "npx playwright test -c ./tests/E2E/playwright.config.ts",
"test:e2e:ui": "npm run test:e2e -- --ui"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@rushstack/eslint-patch": "^1.8.0",
"@tailwindcss/postcss": "^4.0.6",
"@tailwindcss/vite": "^4.1.13",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/user-event": "^14.6.1",
"@testing-library/vue": "^8.1.0",
"@types/node": "^22.13.5",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.8.2",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^27.0.0",
"laravel-vite-plugin": "^1.2.0",
"lucide-vue-next": "^0.475.0",
"prettier": "^3.3.0",
"prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"radix-vue": "^1.9.14",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"vite": "^6.1.0",
"vitest": "^3.2.4",
"vue": "^3.4.0",
"vue-eslint-parser": "^10.2.0",
"vue-router": "^4.2.5",
"vue-tsc": "^2.0.24"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.4",
"@faker-js/faker": "^9.6.0",
"@pinia/testing": "^1.0.2",
"@types/json-schema": "^7.0.15",
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.8.0",
"concurrently": "^9.2.1",
"jsonc-parser": "^3.3.1",
"pinia": "^3.0.1",
"pretty-bytes": "^7.0.1",
"pretty-ms": "^9.3.0",
"reka-ui": "^2.5.0",
"tw-animate-css": "^1.3.8",
"vee-validate": "^4.15.0",
"vue-codemirror": "^6.1.1",
"vue-sonner": "^2.0.8"
}
"private": true,
"type": "module",
"scripts": {
"style:check": "eslint --config tools/eslint/.eslintrc.cjs --ignore-path tools/eslint/.eslintignore resources/js --ext .js,.ts,.vue",
"style:fix": "npm run style:check -- --fix",
"type:check": "vue-tsc --noEmit --project ./resources/tsconfig.json",
"vite": "vite --config vite.config.js",
"build": "npm run type:check && npm run vite -- build && cp -a ./resources/dist-static/. ./resources/dist",
"build:dev": "npm run vite -- build --mode=development && cp -a ./resources/dist-static/. ./resources/dist",
"dev": "npm run vite -- dev",
"test": "vitest --config resources/js/tests/vitest.config.js",
"test:ui": "npm run test -- --ui",
"test:run": "npm run test -- run",
"test:coverage": "npm run test:run --coverage",
"test:watch": "npm run test -- --watch",
"test:snapshot": "npm run test:run --reporter=verbose --update",
"test:e2e": "npx playwright test -c ./tests/E2E/playwright.config.ts",
"test:e2e:ui": "npm run test:e2e -- --ui"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@rushstack/eslint-patch": "^1.8.0",
"@tailwindcss/postcss": "^4.0.6",
"@tailwindcss/vite": "^4.1.13",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/user-event": "^14.6.1",
"@testing-library/vue": "^8.1.0",
"@types/node": "^22.13.5",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.8.2",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^27.0.0",
"laravel-vite-plugin": "^1.2.0",
"lucide-vue-next": "^0.475.0",
"prettier": "^3.3.0",
"prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"radix-vue": "^1.9.14",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"vite": "^6.1.0",
"vitest": "^3.2.4",
"vue": "^3.4.0",
"vue-eslint-parser": "^10.2.0",
"vue-router": "^4.2.5",
"vue-tsc": "^2.0.24"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.4",
"@faker-js/faker": "^9.6.0",
"@pinia/testing": "^1.0.2",
"@types/json-schema": "^7.0.15",
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.8.0",
"concurrently": "^9.2.1",
"jsonc-parser": "^3.3.1",
"pinia": "^3.0.1",
"pretty-bytes": "^7.0.1",
"pretty-ms": "^9.3.0",
"reka-ui": "^2.5.0",
"tw-animate-css": "^1.3.8",
"vee-validate": "^4.15.0",
"vue-codemirror": "^6.1.1",
"vue-sonner": "^2.0.8"
}
}

View File

@@ -7,7 +7,7 @@ const props = defineProps<CollapsibleContentProps>();
<template>
<CollapsibleContent
v-bind="props"
class="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden transition-all"
class="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down transition-all"
>
<slot />
</CollapsibleContent>

View File

@@ -162,6 +162,7 @@ const populateCurlCommandExporterDialog = () => {
v-model="endpoint"
class="h-full flex-1 rounded-none border-0 text-xs shadow-none focus:ring-0 focus-visible:ring-0"
placeholder="<endpoint>"
data-testid="endpoint-input"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
ConstDump,
styles,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
import { cn } from '@/utils';
interface PropsShape {
dump: ConstDump;
}
defineProps<PropsShape>();
</script>
<template>
<span :class="cn(styles.value, 'italic')">
{{ String(dump.value) }}
</span>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { styles } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
withDefaults(defineProps<{ keyName: string | number; numerical?: boolean }>(), {
numerical: false,
});
</script>
<template>
<span v-if="keyName && !numerical" :class="styles.key">"{{ keyName }}":&nbsp;</span>
<span v-else-if="keyName" :class="styles.numericalKey">
{{ String(keyName) }}:&nbsp;
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import {
NumberDump,
styles,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
interface PropsShape {
dump: NumberDump;
}
defineProps<PropsShape>();
</script>
<template>
<span :class="styles.value">{{ dump.value }}</span>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import {
ObjectDumpProperty,
styles,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
import { cn } from '@/utils';
interface PropsShape {
property: ObjectDumpProperty;
keyName: string;
}
defineProps<PropsShape>();
const visibilityColor = (visibility: 'public' | 'protected' | 'private') => {
return {
public: 'text-emerald-600 dark:text-emerald-500',
protected: 'text-amber-600 dark:text-amber-500',
private: 'text-zinc-500 dark:text-zinc-400',
}[visibility];
};
const visibilitySymbol = (visibility: 'public' | 'protected' | 'private') => {
return {
public: '+',
protected: '#',
private: '-',
}[visibility];
};
</script>
<template>
<span :class="styles.key">
<span :class="cn('text-xs', visibilityColor(property.visibility))">
{{ visibilitySymbol(property.visibility) }}
</span>
<span :class="styles.objectProperty">{{ keyName }}:</span>
</span>
</template>

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import {
AppCollapsible,
AppCollapsibleContent,
AppCollapsibleTrigger,
} from '@/components/base/collapsible';
import DumpKeyRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/DumpKeyRenderer.vue';
import {
ArrayDump,
ClosureDump,
ConstDump,
ConstDumpRenderer,
DumpValue,
NumberDump,
NumberDumpRenderer,
ObjectDump,
ObjectDumpProperty,
StringDump,
StringDumpRenderer,
styles,
UninitializedDump,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
import ObjectDumpValuePropertyKey from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/ObjectDumpValuePropertyKey.vue';
import UninitializedDumpRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/UninitializedDumpRenderer.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { ChevronRight } from 'lucide-vue-next';
import { computed, ComputedRef } from 'vue';
interface DumpNodeRendererProps {
dump: DumpValue;
depth?: number;
keyName?: string;
numericalKey?: boolean;
}
const props = withDefaults(defineProps<DumpNodeRendererProps>(), {
depth: 0,
keyName: undefined,
numericalKey: false,
});
const isNestable: ComputedRef<boolean> = computed(
() =>
props.dump.type === DumpValueType.Object ||
props.dump.type === DumpValueType.Closure ||
props.dump.type === DumpValueType.Array,
);
const nestableNodeSummary: ComputedRef<string> = computed(() => {
if (props.dump.type === DumpValueType.Closure) {
return (props.dump as ClosureDump).value.signature;
}
if (props.dump.type === DumpValueType.Object) {
const className = (props.dump as ObjectDump).value.class;
const propCount = (props.dump as ObjectDump).value.propertiesCount;
if (propCount === 0) {
return '{}';
}
return `${className}: ${propCount} ${propCount === 1 ? 'property' : 'properties'}`;
}
if (props.dump.type === DumpValueType.Array) {
const itemCount = (props.dump as ArrayDump).value.length;
if (itemCount === 0) {
return '[]';
}
return `array: ${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
}
throw new Error('Node is not nestable');
});
const nestedValues: ComputedRef<
| Record<string, DumpValue>
| Record<string, ObjectDumpProperty>
| Record<string, string | null>
| never[]
> = computed(() => {
if (props.dump.type === DumpValueType.Closure) {
const thisValue = (props.dump as ClosureDump).value.this;
const classValue = (props.dump as ClosureDump).value.class;
return {
class: {
type: classValue ? DumpValueType.String : DumpValueType.Constant,
value: classValue || 'null',
},
this: {
type: thisValue ? DumpValueType.String : DumpValueType.Constant,
value: thisValue || 'null',
},
};
}
if (props.dump.type === DumpValueType.Object) {
return (props.dump as ObjectDump).value.properties;
}
if (props.dump.type === DumpValueType.Array) {
return (props.dump as ArrayDump).value.items;
}
return [];
});
</script>
<template>
<div v-if="!isNestable" class="flex gap-0.5">
<slot name="key">
<DumpKeyRenderer
v-if="keyName !== undefined"
:key-name="keyName"
:numerical="numericalKey"
/>
</slot>
<div :class="styles.value">
<StringDumpRenderer
v-if="dump.type === DumpValueType.String"
:dump="dump as StringDump"
/>
<NumberDumpRenderer
v-else-if="dump.type === DumpValueType.Number"
:dump="dump as NumberDump"
/>
<ConstDumpRenderer
v-else-if="dump.type === DumpValueType.Constant"
:dump="dump as ConstDump"
/>
<UninitializedDumpRenderer
v-else-if="dump.type === DumpValueType.Uninitialized"
:dump="dump as UninitializedDump"
/>
<div v-else>
<small class="text-rose-500 dark:text-rose-700">
Invalid dump value type `{{ dump.type }}` received. Please create a
<a
class="underline"
href="https://github.com/sunchayn/nimbus/issues/new/choose"
>
Bug
</a>
card.
</small>
</div>
</div>
</div>
<AppCollapsible
v-else
class="flex flex-col"
:default-open="depth === 0"
data-testid="app-collapsible"
>
<AppCollapsibleTrigger
class="group/collapsible-trigger -mx-1 flex items-center gap-1 rounded-sm px-1 text-sm hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50"
data-testid="collapsible-trigger"
:disabled="nestedValues.length === 0"
>
<slot name="key">
<DumpKeyRenderer
v-if="keyName !== undefined"
:key-name="keyName"
:numerical="numericalKey"
/>
</slot>
<span class="flex w-full items-center text-left">
<ChevronRight
class="size-3 text-zinc-500 transition-transform group-data-[state=open]/collapsible-trigger:rotate-90"
/>
<span class="ml-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ nestableNodeSummary }}
</span>
</span>
</AppCollapsibleTrigger>
<AppCollapsibleContent>
<div class="ml-2 border-l border-zinc-200 pl-2 dark:border-zinc-800">
<div
v-for="(nestedValue, key) in nestedValues"
:key="`${keyName ?? 'root'}-${key}`"
class="flex items-center gap-1 py-0.5"
>
<template v-if="dump.type === DumpValueType.Object">
<SingleDumpRenderer
:dump="(nestedValue as ObjectDumpProperty).value"
:depth="depth + 1"
>
<template #key>
<ObjectDumpValuePropertyKey
:key-name="key"
:property="nestedValue as ObjectDumpProperty"
/>
</template>
</SingleDumpRenderer>
</template>
<SingleDumpRenderer
v-else-if="
dump.type === DumpValueType.Array ||
dump.type === DumpValueType.Closure // <- Its structure is transformed in `nestedValues`
"
:dump="nestedValue as DumpValue"
:key-name="String(key)"
:numerical-key="(dump as ArrayDump).value.numericallyIndexed"
:depth="depth + 1"
/>
<div v-else>
<small class="text-rose-500 dark:text-rose-700">
Invalid nested value type `{{ dump.type }}` received. Please
create a
<a
class="underline"
href="https://github.com/sunchayn/nimbus/issues/new/choose"
>
Bug
</a>
card.
</small>
</div>
</div>
</div>
</AppCollapsibleContent>
</AppCollapsible>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
StringDump,
styles,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
interface PropsShape {
dump: StringDump;
}
defineProps<PropsShape>();
</script>
<template>
<span :class="styles.stringValue">
"{{ dump.value }}"
<span :class="styles.meta">({{ dump.value.length }})</span>
</span>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import {
styles,
UninitializedDump,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/index';
import { cn } from '@/utils';
interface PropsShape {
dump: UninitializedDump;
}
defineProps<PropsShape>();
</script>
<template>
<span :class="cn(styles.value, 'italic')">
{{ String(dump.value) }}
<i class="text-xs">(Uninitialized Prop)</i>
</span>
</template>

View File

@@ -0,0 +1,80 @@
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
export interface ArrayDump {
type: DumpValueType.Array;
value: {
items: Record<string, DumpValue>;
length: number;
numericallyIndexed: boolean;
};
}
export interface ObjectDump {
type: DumpValueType.Object;
value: {
class: string;
properties: Record<string, ObjectDumpProperty>;
propertiesCount: number;
};
}
export interface ClosureDump {
type: DumpValueType.Closure;
value: {
signature: string;
class: string | null;
this: string | null;
};
}
export interface StringDump {
type: DumpValueType.String;
value: string;
}
export interface NumberDump {
type: DumpValueType.Number;
value: number;
}
export interface ConstDump {
type: DumpValueType.Constant;
value: boolean | null;
}
export interface UninitializedDump {
type: DumpValueType.Uninitialized;
value: string;
}
export type DumpValue =
| ArrayDump
| ObjectDump
| ClosureDump
| StringDump
| NumberDump
| ConstDump
| UninitializedDump
| {
type: string;
};
export interface ObjectDumpProperty {
visibility: 'public' | 'protected' | 'private';
value: DumpValue;
}
export const styles = {
key: 'text-green-600 dark:text-green-400 font-mono text-xs',
numericalKey: 'text-blue-600 font-mono text-xs',
objectProperty: 'text-zinc-600 dark:text-zinc-400 font-mono text-xs',
value: 'text-xs text-zinc-900 dark:text-zinc-50 font-mono',
stringValue: 'text-xs text-green-600 dark:text-green-400 font-mono',
meta: 'text-zinc-600 dark:text-zinc-400',
};
export { default as ConstDumpRenderer } from './ConstDumpRenderer.vue';
export { default as NumberDumpRenderer } from './NumberDumpRenderer.vue';
export { default as SingleDumpRenderer } from './SingleDumpRenderer.vue';
export { default as StringDumpRenderer } from './StringDumpRenderer.vue';
export { default as UninitializedDumpRenderer } from './UninitializedDumpRenderer.vue';

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { AppButton } from '@/components/base/button';
import AppRoundIndicator from '@/components/base/round-indicator/AppRoundIndicator.vue';
import {
DumpValue,
SingleDumpRenderer,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
import { ChevronLeft, ChevronRight, Trash2Icon } from 'lucide-vue-next';
import { computed, reactive, Ref, ref, watch } from 'vue';
interface DumpSnapshot {
id: string;
timestamp: string;
source: string;
dumps: DumpValue[];
}
interface ResponseDieAndDumpProps {
rawContent: string;
}
const dumpSnapshots: Ref<DumpSnapshot[]> = ref([]);
const props = defineProps<ResponseDieAndDumpProps>();
const emits = defineEmits<{
'update:dumps': [dumps: DumpSnapshot[]];
}>();
const DELETION_CONFIRMATION_TIMEOUT = 1200;
interface DeletionState {
deleting: boolean;
timeoutId?: number;
}
const deletionStatesForDumps = reactive(new Map<string, DeletionState>());
const initiateDumpDeletion = (dumpId: string): boolean => {
const state = deletionStatesForDumps.get(dumpId);
if (state?.deleting) {
clearDumpDeletionState(dumpId);
return true;
}
setDumpDeletionState(dumpId);
return false;
};
const isDumpMarkedForDeletion = (dumpId: string): boolean => {
return deletionStatesForDumps.get(dumpId)?.deleting ?? false;
};
const clearDumpDeletionState = (dumpId: string): void => {
const state = deletionStatesForDumps.get(dumpId);
if (state?.timeoutId) {
clearTimeout(state.timeoutId);
}
deletionStatesForDumps.delete(dumpId);
};
const setDumpDeletionState = (dumpId: string): void => {
const timeoutId: number = window.setTimeout(() => {
deletionStatesForDumps.delete(dumpId);
}, DELETION_CONFIRMATION_TIMEOUT);
deletionStatesForDumps.set(dumpId, {
deleting: true,
timeoutId,
});
};
const handleDeleteDump = (): void => {
const currentDump = selectedDumpsLog.value;
if (!currentDump) {
return;
}
const shouldDelete = initiateDumpDeletion(currentDump.id);
if (!shouldDelete) {
return;
}
const currentIndex = selectedDumpIndex.value;
const dumpsArray = [...dumpSnapshots.value];
dumpsArray.splice(currentIndex, 1);
// Update local state first
dumpSnapshots.value = dumpsArray;
// Then emit the update
emits('update:dumps', dumpsArray);
// Adjust the selected index
if (dumpsArray.length > 0) {
selectedDumpIndex.value = Math.min(currentIndex, dumpsArray.length - 1);
} else {
selectedDumpIndex.value = 0;
}
// Clean up the deletion state
clearDumpDeletionState(currentDump.id);
};
const selectedDumpIndex = ref<number>(0);
watch(
() => props.rawContent,
newValue => {
if (!newValue) {
return;
}
try {
const newDump = JSON.parse(String(newValue)) as DumpSnapshot;
dumpSnapshots.value = [newDump, ...dumpSnapshots.value];
selectedDumpIndex.value = 0;
} catch (error) {
console.error('Failed to parse dump snapshot:', error);
}
},
{ immediate: true },
);
const selectedDumpsLog = computed(() => {
if (dumpSnapshots.value.length === 0) {
return null;
}
return dumpSnapshots.value[selectedDumpIndex.value] ?? null;
});
const hasMultipleDumps = computed(() => {
return dumpSnapshots.value.length > 1;
});
const canGoPrevious = computed(() => {
return selectedDumpIndex.value > 0;
});
const canGoNext = computed(() => {
return selectedDumpIndex.value < dumpSnapshots.value.length - 1;
});
const goToPrevious = () => {
if (canGoPrevious.value) {
selectedDumpIndex.value--;
}
};
const goToNext = () => {
if (canGoNext.value) {
selectedDumpIndex.value++;
}
};
</script>
<template>
<PanelSubHeader class="border-b">
<div class="gap-.5 flex flex-col">
<span class="text-xs text-zinc-900 dark:text-zinc-50">
{{ selectedDumpsLog?.source ?? 'Unknown Source' }}
</span>
</div>
<template #toolbox>
<div class="flex items-center gap-2">
<div v-if="hasMultipleDumps" class="flex items-center gap-1">
<AppButton
variant="ghost"
size="xs"
:disabled="!canGoPrevious"
data-testid="previous-dump-button"
@click="goToPrevious"
>
<ChevronLeft class="size-3" />
</AppButton>
<span class="text-xxs text-zinc-500 select-none dark:text-zinc-400">
{{ selectedDumpIndex + 1 }} /
{{ dumpSnapshots.length }}
</span>
<AppButton
variant="ghost"
size="xs"
:disabled="!canGoNext"
data-testid="next-dump-button"
@click="goToNext"
>
<ChevronRight class="size-3" />
</AppButton>
<AppButton
v-if="selectedDumpsLog"
variant="outline"
size="xs"
class="shadow-none"
data-testid="delete-dump-button"
@click="handleDeleteDump"
>
<Trash2Icon
class="size-3"
:class="{
'text-rose-500 dark:text-rose-700':
isDumpMarkedForDeletion(selectedDumpsLog.id),
}"
/>
</AppButton>
</div>
</div>
</template>
</PanelSubHeader>
<div
class="p-panel bg-subtle-background flex h-full min-h-0 flex-1 flex-col gap-1 overflow-y-auto"
>
<div
v-if="selectedDumpsLog?.timestamp"
class="text-xxs my-1 text-zinc-500 dark:text-zinc-400"
>
Dumped At:
{{ selectedDumpsLog?.timestamp ?? '' }}
</div>
<div
v-if="selectedDumpsLog === undefined || selectedDumpsLog === null"
class="border-subtle bg-card rounded-md border text-sm"
>
<div class="px-panel flex items-center gap-1.5 border-b py-1">
<AppRoundIndicator class="text-zinc-500" />
Info
</div>
<div class="p-panel">Please make sure one dump snapshot is selected.</div>
</div>
<div
v-else-if="selectedDumpsLog.dumps.length === 0"
class="border-subtle bg-card rounded-md border text-sm"
>
<div class="px-panel flex items-center gap-1.5 border-b py-1">
<AppRoundIndicator class="text-rose-500" />
Error
</div>
<div class="p-panel">
<p>
Something Went Wrong! Die and Dump is detected but there is no dumps.
</p>
<small>
If you think this is a bug, please open a
<a
class="underline"
href="https://github.com/sunchayn/nimbus/issues/new/choose"
>
Bug
</a>
card.
</small>
</div>
</div>
<div v-else :key="selectedDumpsLog.id" class="flex flex-col gap-2.5">
<div
v-for="(dump, index) in selectedDumpsLog?.dumps ?? []"
:key="index"
class="border-subtle bg-card rounded-md border"
:data-testid="`dump-value-${index}`"
>
<div class="px-panel relative z-10 flex gap-1.5 border-b py-1">
<AppRoundIndicator class="text-subtle-foreground pt-1" />
<span class="inline-flex flex-col" data-testid="dump-value-title">
<span class="text-xs">Dump #{{ index + 1 }}</span>
</span>
</div>
<div
class="p-panel w-full overflow-x-auto whitespace-nowrap"
data-testid="dump-value-content"
>
<SingleDumpRenderer
v-if="selectedDumpsLog"
class="w-full max-w-full"
:dump="dump"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -13,12 +13,12 @@ const props = defineProps<ResponseStatusCodeProps>();
<template>
<div class="flex items-center space-x-2">
<StatusIndicator :status="props.status" />
<StatusIndicator :status="props.status" data-testid="response-status-indicator" />
<span class="text-xs text-nowrap" data-testid="response-status-text">
{{ props.status }}
</span>
<AppBadge
v-if="props.response"
v-if="props.response && props.status !== STATUS.DUMP_AND_DIE"
variant="outline"
class="text-nowrap"
data-testid="response-status-badge"
@@ -27,7 +27,7 @@ const props = defineProps<ResponseStatusCodeProps>();
{{ props.response.statusText }}
</AppBadge>
<AppBadge
v-else-if="props.status !== STATUS.EMPTY"
v-else-if="props.status === STATUS.PENDING"
variant="outline"
class="text-nowrap"
>

View File

@@ -19,6 +19,7 @@ const variants = {
[STATUS.SERVER_ERROR]: 'text-rose-500',
[STATUS.OTHER]: 'text-zinc-500',
[STATUS.EMPTY]: 'text-zinc-900',
[STATUS.DUMP_AND_DIE]: 'text-violet-600',
[STATUS.PENDING]: '',
};

View File

@@ -8,8 +8,10 @@ import {
import ResponseBody from '@/components/domain/Client/Response/ResponseBody/ResponseBody.vue';
import ResponseCookies from '@/components/domain/Client/Response/ResponseCookies/ResponseCookies.vue';
import ResponseHeaders from '@/components/domain/Client/Response/ResponseHeaders/ResponseHeaders.vue';
import { STATUS } from '@/interfaces/http';
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
import { computed } from 'vue';
import ResponseDumpAndDie from './ResponseBody/ResponseDumpAndDie.vue';
const historyStore = useRequestsHistoryStore();
const requestStore = useRequestStore();
@@ -21,7 +23,7 @@ const pendingRequestData = computed(() => requestStore.pendingRequestData);
<div class="relative min-h-0 flex-1">
<div
v-if="pendingRequestData?.isProcessing"
class="bg-background absolute top-0 left-0 z-10 h-full w-full animate-pulse opacity-75"
class="bg-background absolute top-0 left-0 z-[100] h-full w-full animate-pulse opacity-75"
/>
<AppTabs default-value="response" class="mt-0 flex h-full flex-col overflow-auto">
<div class="bg-subtle-background border-b">
@@ -31,11 +33,20 @@ const pendingRequestData = computed(() => requestStore.pendingRequestData);
<AppTabsTrigger value="response-cookies" label="Cookies" />
</AppTabsList>
</div>
<AppTabsContent value="response" class="mt-0 min-h-0 flex-1 overflow-hidden">
<AppTabsContent
value="response"
class="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden"
>
<ResponseBody
v-if="lastLog?.response?.status !== STATUS.DUMP_AND_DIE"
class="min-h-0 overflow-auto"
:content="lastLog?.response?.body ?? ''"
/>
<ResponseDumpAndDie
v-else
:raw-content="lastLog?.response?.body ?? '[]'"
/>
</AppTabsContent>
<AppTabsContent
value="response-headers"

View File

@@ -35,7 +35,7 @@ const props = defineProps({
</span>
</AppSidebarMenuButton>
</AppCollapsibleTrigger>
<AppCollapsibleContent>
<AppCollapsibleContent class="overflow-hidden">
<AppSidebarMenuSub>
<slot />
</AppSidebarMenuSub>

View File

@@ -3,7 +3,7 @@
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: 2025-09-22T17:58:32+00:00.
* Generated at: 2026-01-02T00:36:13+00:00.
*/
export enum AuthorizationType {

View File

@@ -0,0 +1,18 @@
/*
* This file is auto-generated.
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: 2026-01-02T00:36:13+00:00.
*/
export enum DumpValueType {
Object = 'object',
Array = 'array',
String = 'string',
Constant = 'constant',
Uninitialized = 'uninitialized',
Number = 'number',
Closure = 'closure',
Unknown = 'unknown',
}

View File

@@ -3,7 +3,7 @@
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: 2025-09-22T17:58:32+00:00.
* Generated at: 2026-01-02T00:36:13+00:00.
*/
export enum GeneratorType {

View File

@@ -7,4 +7,5 @@ export enum STATUS {
EMPTY = 'No request yet',
PENDING = 'Pending',
OTHER = 'Other',
DUMP_AND_DIE = 'Dump & Die',
}

View File

@@ -0,0 +1,101 @@
import type { ConstDump } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import ConstDumpRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/ConstDumpRenderer.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
describe('ConstDumpRenderer', () => {
it('renders true as string', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: true,
};
renderWithProviders(ConstDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('true')).toBeInTheDocument();
});
it('renders false as string', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: false,
};
renderWithProviders(ConstDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('false')).toBeInTheDocument();
});
it('renders null as string', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: null,
};
renderWithProviders(ConstDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('null')).toBeInTheDocument();
});
it('applies italic styling', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: true,
};
const { container } = renderWithProviders(ConstDumpRenderer, {
props: { dump },
});
await nextTick();
const span = container.querySelector('span');
expect(span?.className).toContain('italic');
});
it('applies correct CSS classes', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: true,
};
const { container } = renderWithProviders(ConstDumpRenderer, {
props: { dump },
});
await nextTick();
const span = container.querySelector('span');
expect(span?.className).toContain('text-xs');
expect(span?.className).toContain('font-mono');
});
it('handles keyName prop when provided', async () => {
const dump: ConstDump = {
type: DumpValueType.Constant,
value: true,
};
renderWithProviders(ConstDumpRenderer, {
props: { dump, keyName: 'myKey' },
});
await nextTick();
expect(screen.getByText('true')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,175 @@
import type { NumberDump } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import NumberDumpRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/NumberDumpRenderer.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
describe('NumberDumpRenderer', () => {
it('renders number value correctly', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 42,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('handles positive numbers', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 123,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('123')).toBeInTheDocument();
});
it('handles negative numbers', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: -42,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('-42')).toBeInTheDocument();
});
it('handles zero', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 0,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('handles decimal numbers', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 3.14159,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('3.14159')).toBeInTheDocument();
});
it('handles very large numbers', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 1e20,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('100000000000000000000')).toBeInTheDocument();
});
it('handles very small numbers', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 1e-10,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('1e-10')).toBeInTheDocument();
});
it('applies correct CSS classes', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: 42,
};
const { container } = renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
const span = container.querySelector('span');
expect(span?.className).toContain('text-xs');
expect(span?.className).toContain('font-mono');
});
it('handles Infinity', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: Infinity,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('Infinity')).toBeInTheDocument();
});
it('handles -Infinity', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: -Infinity,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('-Infinity')).toBeInTheDocument();
});
it('handles NaN', async () => {
const dump: NumberDump = {
type: DumpValueType.Number,
value: NaN,
};
renderWithProviders(NumberDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText('NaN')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
import type { ObjectDumpProperty } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import ObjectDumpValuePropertyKey from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/ObjectDumpValuePropertyKey.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
const createProperty = (
visibility: 'public' | 'protected' | 'private',
): ObjectDumpProperty => ({
visibility,
value: {
type: DumpValueType.String,
value: 'test',
},
});
describe('ObjectDumpValuePropertyKey', () => {
it('displays public property with + symbol and emerald color', async () => {
const property = createProperty('public');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'publicProp' },
});
await nextTick();
expect(screen.getByText('+')).toBeInTheDocument();
expect(screen.getByText('publicProp:')).toBeInTheDocument();
const span = screen.getByText('+');
expect(span?.className).toContain('text-emerald-600');
});
it('displays protected property with # symbol and amber color', async () => {
const property = createProperty('protected');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'protectedProp' },
});
await nextTick();
expect(screen.getByText('#')).toBeInTheDocument();
expect(screen.getByText('protectedProp:')).toBeInTheDocument();
const span = screen.getByText('#');
expect(span?.className).toContain('text-amber-600');
});
it('displays private property with - symbol and zinc color', async () => {
const property = createProperty('private');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'privateProp' },
});
await nextTick();
expect(screen.getByText('-')).toBeInTheDocument();
expect(screen.getByText('privateProp:')).toBeInTheDocument();
const span = screen.getByText('-');
expect(span?.className).toContain('text-zinc-500');
});
it('displays key name after visibility symbol', async () => {
const property = createProperty('public');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'myProperty' },
});
await nextTick();
const container = screen.getByText('myProperty:').parentElement;
expect(container?.textContent).toContain('+');
expect(container?.textContent).toContain('myProperty:');
});
it('applies correct CSS classes for public visibility', async () => {
const property = createProperty('public');
const { container } = renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'prop' },
});
await nextTick();
const visibilitySpan = container.querySelector('span > span');
expect(visibilitySpan?.className).toContain('text-emerald-600');
expect(visibilitySpan?.className).toContain('dark:text-emerald-500');
});
it('applies correct CSS classes for protected visibility', async () => {
const property = createProperty('protected');
const { container } = renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'prop' },
});
await nextTick();
const visibilitySpan = container.querySelector('span > span');
expect(visibilitySpan?.className).toContain('text-amber-600');
expect(visibilitySpan?.className).toContain('dark:text-amber-500');
});
it('applies correct CSS classes for private visibility', async () => {
const property = createProperty('private');
const { container } = renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'prop' },
});
await nextTick();
const visibilitySpan = container.querySelector('span > span');
expect(visibilitySpan?.className).toContain('text-zinc-500');
expect(visibilitySpan?.className).toContain('dark:text-zinc-400');
});
it('handles empty key name', async () => {
const property = createProperty('public');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: '' },
});
await nextTick();
expect(screen.getByText('+')).toBeInTheDocument();
expect(screen.getByText(':')).toBeInTheDocument();
});
it('handles special characters in key name', async () => {
const property = createProperty('public');
renderWithProviders(ObjectDumpValuePropertyKey, {
props: { property, keyName: 'prop_with_underscore' },
});
await nextTick();
expect(screen.getByText('prop_with_underscore:')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,680 @@
import type {
ArrayDump,
ClosureDump,
ConstDump,
DumpValue,
NumberDump,
ObjectDump,
StringDump,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import SingleDumpRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/SingleDumpRenderer.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
vi.mock('@/components/base/collapsible', () => ({
AppCollapsible: {
name: 'AppCollapsible',
template: `
<div data-testid="app-collapsible" :data-default-open="defaultOpen">
<slot />
</div>
`,
props: {
defaultOpen: Boolean,
class: String,
},
},
AppCollapsibleContent: {
name: 'AppCollapsibleContent',
template: '<div data-testid="collapsible-content"><slot /></div>',
},
AppCollapsibleTrigger: {
name: 'AppCollapsibleTrigger',
template: '<button data-testid="collapsible-trigger"><slot /></button>',
props: {
class: String,
},
},
}));
vi.mock(
'@/components/domain/Client/Response/ResponseBody/DumpRenderer/StringDumpRenderer.vue',
() => ({
default: {
name: 'StringDumpRenderer',
template: '<span data-testid="string-dump-renderer">{{ dump.value }}</span>',
props: {
dump: Object,
keyName: String,
},
},
}),
);
vi.mock(
'@/components/domain/Client/Response/ResponseBody/DumpRenderer/NumberDumpRenderer.vue',
() => ({
default: {
name: 'NumberDumpRenderer',
template: '<span data-testid="number-dump-renderer">{{ dump.value }}</span>',
props: {
dump: Object,
keyName: String,
},
},
}),
);
vi.mock(
'@/components/domain/Client/Response/ResponseBody/DumpRenderer/ConstDumpRenderer.vue',
() => ({
default: {
name: 'ConstDumpRenderer',
template: '<span data-testid="const-dump-renderer">{{ dump.value }}</span>',
props: {
dump: Object,
keyName: String,
},
},
}),
);
vi.mock(
'@/components/domain/Client/Response/ResponseBody/DumpRenderer/ObjectDumpValuePropertyKey.vue',
() => ({
default: {
name: 'ObjectDumpValuePropertyKey',
template: '<span data-testid="property-key">{{ keyName }}</span>',
props: {
keyName: String,
property: Object,
class: String,
},
},
}),
);
vi.mock('lucide-vue-next', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
ChevronRight: {
name: 'ChevronRight',
template: '<svg data-testid="chevron-right" />',
},
};
});
const createStringDump = (value: string): StringDump => ({
type: DumpValueType.String,
value,
});
const createNumberDump = (value: number): NumberDump => ({
type: DumpValueType.Number,
value,
});
const createConstDump = (value: boolean | null): ConstDump => ({
type: DumpValueType.Constant,
value,
});
const createClosureDump = (
signature: string = 'Closure()',
className: string | null = null,
thisValue: string | null = null,
): ClosureDump => ({
type: DumpValueType.Closure,
value: {
signature,
class: className,
this: thisValue,
},
});
const createObjectDump = (
className: string,
properties: Record<
string,
{ visibility: 'public' | 'protected' | 'private'; value: DumpValue }
>,
): ObjectDump => ({
type: DumpValueType.Object,
value: {
class: className,
properties,
propertiesCount: Object.keys(properties).length,
},
});
const createArrayDump = (
items: Record<string, DumpValue>,
numericallyIndexed: boolean = true,
): ArrayDump => ({
type: DumpValueType.Array,
value: {
items,
length: Object.keys(items).length,
numericallyIndexed,
},
});
describe('SingleDumpRenderer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Non-nestable Types', () => {
it('renders StringDumpRenderer for string type', async () => {
const dump = createStringDump('test-string');
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('string-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('string-dump-renderer')).toHaveTextContent(
'test-string',
);
});
it('renders NumberDumpRenderer for number type', async () => {
const dump = createNumberDump(42);
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('number-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('number-dump-renderer')).toHaveTextContent('42');
});
it('renders ConstDumpRenderer for const type', async () => {
const dump = createConstDump(true);
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('const-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('const-dump-renderer')).toHaveTextContent('true');
});
it('renders ClosureDumpRenderer for closure type', async () => {
const dump = createClosureDump(
'Closure(Application $app)',
'MyClass',
'thisValue',
);
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('app-collapsible')).toBeInTheDocument();
expect(screen.getByTestId('app-collapsible')).toHaveTextContent(
'Closure(Application $app)',
);
});
it('displays key name when provided for string type', async () => {
const dump = createStringDump('test');
const { container } = renderWithProviders(SingleDumpRenderer, {
props: { dump, keyName: 'myKey' },
});
await nextTick();
expect(container.textContent).toEqual('"myKey": test');
expect(screen.getByTestId('string-dump-renderer')).toHaveTextContent('test');
});
it('shows error message for unknown types', async () => {
const dump = { type: 'unknown-type' } as DumpValue;
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/Invalid dump value type/)).toBeInTheDocument();
expect(screen.getByText(/`unknown-type`/)).toBeInTheDocument();
});
});
describe('Nestable Types', () => {
it('renders collapsible for object type', async () => {
const dump = createObjectDump('MyClass', {});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('app-collapsible')).toBeInTheDocument();
expect(screen.getByTestId('collapsible-trigger')).toBeInTheDocument();
expect(screen.getByTestId('chevron-right')).toBeInTheDocument();
});
it('renders collapsible for array type', async () => {
const dump = createArrayDump({});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('app-collapsible')).toBeInTheDocument();
expect(screen.getByTestId('collapsible-trigger')).toBeInTheDocument();
expect(screen.getByTestId('chevron-right')).toBeInTheDocument();
});
it('shows correct summary text for object with properties', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
prop2: { visibility: 'private', value: createNumberDump(42) },
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('MyClass: 2 properties');
});
it('shows correct summary text for object with single property', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('MyClass: 1 property');
});
it('shows correct summary text for object with no properties', async () => {
const dump = createObjectDump('MyClass', {});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('{}');
});
it('shows correct summary text for array with items', async () => {
const dump = createArrayDump({
'0': createStringDump('item1'),
'1': createStringDump('item2'),
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('array: 2 items');
});
it('shows correct summary text for array with single item', async () => {
const dump = createArrayDump({
'0': createStringDump('item1'),
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('array: 1 item');
});
it('shows correct summary text for empty array', async () => {
const dump = createArrayDump({});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('[]');
});
it('is open by default when depth is 0', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump, depth: 0 },
});
await nextTick();
const collapsible = screen.getByTestId('app-collapsible');
expect(collapsible.getAttribute('data-default-open')).toBe('true');
});
it('is closed by default when depth > 0', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump, depth: 1 },
});
await nextTick();
const collapsible = screen.getByTestId('app-collapsible');
expect(collapsible.getAttribute('data-default-open')).toBe('false');
});
it('displays key name for nestable types', async () => {
const dump = createObjectDump('MyClass', {});
const { container } = renderWithProviders(SingleDumpRenderer, {
props: { dump, keyName: 'myObject' },
});
await nextTick();
expect(container.textContent).toContain('"myObject": {}');
});
});
describe('Nested Rendering', () => {
it('recursively renders nested object properties', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
prop2: { visibility: 'private', value: createNumberDump(42) },
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
// Should render property keys
const propertyKeys = screen.getAllByTestId('property-key');
expect(propertyKeys).toHaveLength(2);
// Should render the actual values
expect(screen.getByTestId('string-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('number-dump-renderer')).toBeInTheDocument();
});
it('recursively renders array items', async () => {
const dump = createArrayDump(
{
value: createStringDump('item1'),
'value-2': createNumberDump(42),
},
false,
);
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const content = screen.getByTestId('collapsible-content');
expect(content).toBeInTheDocument();
// Should render both item values
expect(screen.getByTestId('string-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('number-dump-renderer')).toBeInTheDocument();
});
it('passes correct depth to nested renderers', async () => {
const nestedDump = createObjectDump('NestedClass', {
nestedProp: {
visibility: 'public',
value: createStringDump('nested'),
},
});
const dump = createObjectDump('MyClass', {
nested: { visibility: 'public', value: nestedDump },
});
renderWithProviders(SingleDumpRenderer, {
props: { dump, depth: 0 },
});
await nextTick();
// Both outer and nested objects should have collapsibles
const collapsibles = screen.getAllByTestId('app-collapsible');
expect(collapsibles.length).toBeGreaterThanOrEqual(2);
// Outer should be open (depth 0)
expect(collapsibles[0].getAttribute('data-default-open')).toBe('true');
});
it('passes correct key names to nested renderers', async () => {
const dump = createObjectDump('MyClass', {
myProperty: {
visibility: 'public',
value: createStringDump('value'),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const propertyKey = screen.getByTestId('property-key');
expect(propertyKey).toHaveTextContent('myProperty');
});
it('uses ObjectDumpValuePropertyKey for object properties', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: createStringDump('value1'),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('property-key')).toBeInTheDocument();
expect(screen.getByTestId('property-key')).toHaveTextContent('prop1');
});
it('renders array items with numeric keys', async () => {
const dump = createArrayDump({
'0': createStringDump('item1'),
'1': createStringDump('item2'),
});
const { container } = renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
// Array items should show their index keys
expect(container.textContent).toContain('0:');
expect(container.textContent).toContain('1:');
});
});
describe('Edge Cases', () => {
it('handles invalid nested value types in objects', async () => {
const dump = createObjectDump('MyClass', {
prop1: {
visibility: 'public',
value: { type: 'invalid-type' } as DumpValue,
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/Invalid dump value type/)).toBeInTheDocument();
});
it('handles missing properties gracefully', async () => {
const dump = createObjectDump('MyClass', {});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
const trigger = screen.getByTestId('collapsible-trigger');
expect(trigger.textContent).toContain('{}');
// Should still render collapsible structure
expect(screen.getByTestId('app-collapsible')).toBeInTheDocument();
});
it('handles deeply nested structures', async () => {
const level3 = createObjectDump('Level3', {
prop: { visibility: 'public', value: createStringDump('deep') },
});
const level2 = createObjectDump('Level2', {
nested: { visibility: 'public', value: level3 },
});
const level1 = createObjectDump('Level1', {
nested: { visibility: 'public', value: level2 },
});
renderWithProviders(SingleDumpRenderer, {
props: { dump: level1 },
});
await nextTick();
// Should render multiple nested collapsibles
const collapsibles = screen.getAllByTestId('app-collapsible');
expect(collapsibles.length).toBeGreaterThanOrEqual(3);
// The deepest string value should be rendered
expect(screen.getByTestId('string-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('string-dump-renderer')).toHaveTextContent('deep');
});
it('handles mixed nested types', async () => {
const dump = createObjectDump('MyClass', {
stringProp: {
visibility: 'public',
value: createStringDump('text'),
},
numberProp: {
visibility: 'public',
value: createNumberDump(123),
},
constProp: {
visibility: 'public',
value: createConstDump(true),
},
arrayProp: {
visibility: 'public',
value: createArrayDump({ '0': createStringDump('item') }),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getAllByTestId('string-dump-renderer').length).toEqual(2);
expect(screen.getByTestId('number-dump-renderer')).toBeInTheDocument();
expect(screen.getByTestId('const-dump-renderer')).toBeInTheDocument();
// Nested array should also be rendered
const collapsibles = screen.getAllByTestId('app-collapsible');
expect(collapsibles.length).toBeGreaterThanOrEqual(2); // Main object + nested array
});
it('handles empty arrays in object properties', async () => {
const dump = createObjectDump('MyClass', {
emptyArray: {
visibility: 'public',
value: createArrayDump({}),
},
});
renderWithProviders(SingleDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByTestId('property-key')).toHaveTextContent('emptyArray');
// Should show [] for empty array
const content = screen.getAllByTestId('collapsible-trigger')[1];
console.log(content);
expect(content.textContent).toContain('[]');
});
});
});

View File

@@ -0,0 +1,120 @@
import type { StringDump } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import StringDumpRenderer from '@/components/domain/Client/Response/ResponseBody/DumpRenderer/StringDumpRenderer.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
describe('StringDumpRenderer', () => {
it('renders string value in quotes', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: 'test-string',
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
const element = screen.getByText(/"test-string"/);
expect(element).toBeInTheDocument();
});
it('displays string length in parentheses', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: 'hello',
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/\(5\)/)).toBeInTheDocument();
});
it('applies correct CSS classes', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: 'test',
};
const { container } = renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
const span = container.querySelector('span');
expect(span?.className).toContain('text-xs');
expect(span?.className).toContain('font-mono');
});
it('handles empty strings', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: '',
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/""/)).toBeInTheDocument();
expect(screen.getByText(/\(0\)/)).toBeInTheDocument();
});
it('handles long strings', async () => {
const longString = 'a'.repeat(1000);
const dump: StringDump = {
type: DumpValueType.String,
value: longString,
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(new RegExp(`"${longString}"`))).toBeInTheDocument();
expect(screen.getByText(/\(1000\)/)).toBeInTheDocument();
});
it('handles unicode characters', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: '测试 🎉',
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/"测试 🎉"/)).toBeInTheDocument();
expect(screen.getByText(/\(5\)/)).toBeInTheDocument();
});
it('handles strings with quotes', async () => {
const dump: StringDump = {
type: DumpValueType.String,
value: 'string with "quotes"',
};
renderWithProviders(StringDumpRenderer, {
props: { dump },
});
await nextTick();
expect(screen.getByText(/"string with "quotes""/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,573 @@
import type { DumpValue } from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import ResponseDumpAndDie from '@/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.vue';
import { DumpValueType } from '@/interfaces/generated/dump-value-types';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { fireEvent } from '@testing-library/vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
vi.mock('@/components/layout/PanelSubHeader/PanelSubHeader.vue', () => ({
default: {
name: 'PanelSubHeader',
template: `
<div data-testid="panel-subheader">
<slot />
<div data-testid="toolbox">
<slot name="toolbox" />
</div>
</div>
`,
},
}));
vi.mock('@/components/base/round-indicator/AppRoundIndicator.vue', () => ({
default: {
name: 'AppRoundIndicator',
template: '<div data-testid="round-indicator" />',
props: ['class'],
},
}));
vi.mock('@/components/domain/Client/Response/ResponseBody/DumpRenderer', () => ({
SingleDumpRenderer: {
name: 'SingleDumpRenderer',
template: '<div data-testid="single-dump-renderer" />',
props: ['dump', 'class'],
},
}));
vi.mock('@/components/base/tooltip/AppTooltipWrapper.vue', () => ({
default: {
name: 'AppTooltipWrapper',
template: '<div><slot /></div>',
},
}));
vi.mock('lucide-vue-next', () => ({
ChevronLeft: {
name: 'ChevronLeft',
template: '<svg data-testid="chevron-left" />',
},
ChevronRight: {
name: 'ChevronRight',
template: '<svg data-testid="chevron-right" />',
},
Trash2Icon: {
name: 'Trash2Icon',
template: '<svg data-testid="trash-icon" />',
},
}));
interface DumpSnapshot {
id: string;
timestamp: string;
source: string;
dumps: DumpValue[];
}
const createDumpSnapshot = (
id: string,
source: string = 'Test Source',
timestamp: string = '2024-01-01 12:00:00',
dumps: DumpValue[] = [],
): DumpSnapshot => ({
id,
timestamp,
source,
dumps,
});
const createStringDump = (value: string): DumpValue => ({
type: DumpValueType.String,
value,
});
describe('ResponseDumpAndDie', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Initialization & Parsing', () => {
it('parses rawContent prop and creates dump snapshots', async () => {
const snapshot = createDumpSnapshot(
'1',
'Test Source',
'2024-01-01 12:00:00',
[createStringDump('test')],
);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText('Test Source')).toBeInTheDocument();
});
it('handles immediate prop parsing on mount', async () => {
const snapshot = createDumpSnapshot(
'1',
'Immediate Source',
'2024-01-01 12:00:00',
[createStringDump('test')],
);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText('Immediate Source')).toBeInTheDocument();
});
it('resets selected index when new dump arrives', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
expect(screen.getByText('First')).toBeInTheDocument();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
expect(screen.getByText('Second')).toBeInTheDocument();
});
it('handles invalid JSON gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: 'invalid json' },
});
await nextTick();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('Snapshot Management', () => {
it('displays source and timestamp from snapshot', async () => {
const snapshot = createDumpSnapshot('1', 'My Source', '2024-01-01 12:00:00', [
createStringDump('test'),
]);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText('My Source')).toBeInTheDocument();
expect(screen.getByText(/Dumped At:/)).toBeInTheDocument();
expect(
screen.getByText('Dumped At: 2024-01-01 12:00:00'),
).toBeInTheDocument();
});
it('shows "Unknown Source" when source is missing', async () => {
const snapshot = {
id: '1',
timestamp: '2024-01-01 12:00:00',
dumps: [createStringDump('test')],
};
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText('Unknown Source')).toBeInTheDocument();
});
it('handles empty dumps array (error state)', async () => {
const snapshot = createDumpSnapshot(
'1',
'Test Source',
'2024-01-01 12:00:00',
[],
);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText(/Something Went Wrong!/)).toBeInTheDocument();
expect(screen.getByText(/no dumps/)).toBeInTheDocument();
});
it('handles null/undefined selected dump (info state)', async () => {
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify({ id: '1', dumps: [] }) },
});
await nextTick();
expect(
screen.getByText(
/Something Went Wrong! Die and Dump is detected but there is no dumps./,
),
).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('previous/next buttons only visible when multiple dumps exist', async () => {
const snapshot = createDumpSnapshot('1', 'Test', '2024-01-01 12:00:00', [
createStringDump('test'),
]);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
const prevButton = screen.queryByTestId('previous-dump-button');
const nextButton = screen.queryByTestId('next-dump-button');
expect(prevButton).toBeNull();
expect(nextButton).toBeNull();
});
it('navigation buttons disabled at boundaries', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
const prevButton = screen.getByTestId('previous-dump-button');
const nextButton = screen.getByTestId('next-dump-button');
expect(prevButton).toBeDefined();
expect(nextButton).toBeDefined();
expect(prevButton?.hasAttribute('disabled')).toBe(true);
expect(nextButton?.hasAttribute('disabled')).toBe(false);
});
it('correct index display (1-based)', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
expect(screen.getByText(/1 \/ 2/)).toBeInTheDocument();
});
it('navigation updates selected dump correctly', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
expect(screen.getByText('Second')).toBeInTheDocument();
const nextButton = screen.getByTestId('next-dump-button');
await fireEvent.click(nextButton);
await nextTick();
expect(screen.getByText('First')).toBeInTheDocument();
});
});
describe('Deletion Flow', () => {
it('first click marks dump for deletion (trash icon turns red)', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
const deleteButton = screen.getByTestId('delete-dump-button');
await fireEvent.click(deleteButton);
await nextTick();
const trashIcon = deleteButton.querySelector('[data-testid="trash-icon"]');
expect(trashIcon?.classList.toString()).toContain('text-rose-500');
});
it('second click within timeout confirms deletion', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender, emitted } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
const deleteButton = screen.getByTestId('delete-dump-button');
await fireEvent.click(deleteButton);
await nextTick();
await fireEvent.click(deleteButton);
await nextTick();
expect(emitted('update:dumps')).toBeDefined();
expect(emitted('update:dumps')[0]).toHaveLength(1);
});
it('deletion emits update:dumps event with updated array', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender, emitted } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
const deleteButton = screen.getByTestId('delete-dump-button');
await fireEvent.click(deleteButton);
await nextTick();
await fireEvent.click(deleteButton);
await nextTick();
expect(emitted('update:dumps')).toBeDefined();
expect(emitted('update:dumps')[0]).toHaveLength(1);
});
it('after deletion, adjusts index correctly', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const snapshot3 = createDumpSnapshot('3', 'Third', '2024-01-01 12:02:00', [
createStringDump('third'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot3) });
await nextTick();
const nextButton = screen.getByTestId('next-dump-button');
await fireEvent.click(nextButton);
await nextTick();
expect(screen.getByText('Second')).toBeInTheDocument();
const deleteButton = screen.getByTestId('delete-dump-button');
await fireEvent.click(deleteButton);
await nextTick();
await fireEvent.click(deleteButton);
await nextTick();
expect(screen.getByText('First')).toBeInTheDocument();
});
it('clears deletion state after timeout', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
const deleteButton = screen.getByTestId('delete-dump-button');
await fireEvent.click(deleteButton);
await nextTick();
const trashIcon = deleteButton.querySelector('[data-testid="trash-icon"]');
expect(trashIcon?.classList.toString()).toContain('text-rose-500');
vi.advanceTimersByTime(1200);
await nextTick();
expect(trashIcon?.classList.toString()).not.toContain('text-rose-500');
});
it('handles deletion when no dump selected (early return)', async () => {
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify({ id: '1', dumps: [] }) },
});
await nextTick();
const deleteButton = screen.queryByTestId('delete-dump-button');
expect(deleteButton).toBeNull();
});
});
describe('Rendering', () => {
it('renders all dumps from selected snapshot', async () => {
const snapshot = createDumpSnapshot('1', 'Test', '2024-01-01 12:00:00', [
createStringDump('first'),
createStringDump('second'),
createStringDump('third'),
]);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
const renderers = screen.getAllByTestId('single-dump-renderer');
expect(renderers).toHaveLength(3);
});
it('displays dump numbers correctly (1-based)', async () => {
const snapshot = createDumpSnapshot('1', 'Test', '2024-01-01 12:00:00', [
createStringDump('first'),
createStringDump('second'),
]);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
expect(screen.getByText('Dump #1')).toBeInTheDocument();
expect(screen.getByText('Dump #2')).toBeInTheDocument();
});
it('passes correct dump prop to SingleDumpRenderer', async () => {
const dump = createStringDump('test-value');
const snapshot = createDumpSnapshot('1', 'Test', '2024-01-01 12:00:00', [
dump,
]);
renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot) },
});
await nextTick();
const renderer = screen.getByTestId('single-dump-renderer');
expect(renderer).toBeInTheDocument();
});
});
});

View File

@@ -43,7 +43,7 @@ describe('ResponseStatus', () => {
expect(screen.queryByTestId('response-badge')).toBeNull();
expect(screen.getByTestId('pending-request-spinner')).toBeInTheDocument();
expect(screen.getByTestId('response-status-indicator')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});

View File

@@ -27,5 +27,9 @@ export const getStatusGroup = (statusCode: number): STATUS => {
return STATUS.SERVER_ERROR;
}
if (statusCode === 999) {
return STATUS.DUMP_AND_DIE;
}
return STATUS.OTHER;
};

View File

@@ -21,6 +21,7 @@ class GenerateIntellisenseCommand extends SymfonyCommand
protected array $intellisenseProviders = [
IntellisenseProviders\AuthorizationTypeIntellisense::class,
IntellisenseProviders\RandomValueGeneratorIntellisense::class,
IntellisenseProviders\DumpValueTypeIntellisense::class,
];
const TARGET_BASE_PATH = '/resources/js/interfaces/generated/';

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\IntellisenseProviders;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
/**
* Generates TypeScript types for dump value types from the backend enum.
*/
class DumpValueTypeIntellisense implements IntellisenseContract
{
public const STUB = 'dump-value-types.ts.stub';
public function getTargetFileName(): string
{
return Str::remove('.stub', self::STUB);
}
public function generate(): string
{
$enumCases = [];
foreach (DumpValueTypeEnum::cases() as $case) {
$enumCases[] = sprintf(" %s = '%s',", $case->name, $case->value);
}
$enumContent = implode("\n", $enumCases);
return $this->replaceStubContent($enumContent);
}
private function replaceStubContent(string $enumList): string
{
$stubFile = file_get_contents(__DIR__.'/stubs/'.self::STUB) ?: throw new RuntimeException('Cannot read stub file.');
return str_replace('{{ content }}', rtrim($enumList), $stubFile);
}
}

View File

@@ -0,0 +1,3 @@
export enum DumpValueType {
{{ content }}
}

View File

@@ -7,15 +7,17 @@ use GuzzleHttp\Cookie\SetCookie;
use Illuminate\Container\Container;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Response;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Sunchayn\Nimbus\Modules\Relay\Responses\DumpAndDieResponse;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class RequestRelayAction
{
@@ -25,6 +27,7 @@ class RequestRelayAction
public const NON_STANDARD_STATUS_CODES = [
419 => 'Method Not Allowed',
DumpAndDieResponse::DUMP_AND_DIE_STATUS_CODE => 'dd()',
];
public function __construct(
@@ -44,10 +47,7 @@ class RequestRelayAction
$start = hrtime(true);
$response = $pendingRequest->send(
method: $requestRelayData->method,
url: $requestRelayData->endpoint,
);
$response = $this->sendPendingRequest($pendingRequest, $requestRelayData);
$durationInMs = $this->calculateDuration($start);
@@ -142,8 +142,26 @@ class RequestRelayAction
*/
private function getStatusTextFromCode(int $statusCode): string
{
$statusCodeToTextMapping = Response::$statusTexts + self::NON_STANDARD_STATUS_CODES;
$statusCodeToTextMapping = SymfonyResponse::$statusTexts + self::NON_STANDARD_STATUS_CODES;
return $statusCodeToTextMapping[$statusCode] ?? 'Non-standard Status Code.';
}
private function sendPendingRequest(PendingRequest $pendingRequest, RequestRelayData $requestRelayData): Response
{
$response = $pendingRequest->send(
method: $requestRelayData->method,
url: $requestRelayData->endpoint,
);
if (! $response->serverError()) {
return $response;
}
if (! str_contains($response->body(), 'Sfdump = window.Sfdump')) {
return $response;
}
return new DumpAndDieResponse($response->toPsrResponse());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|array>
*/
readonly class ObjectPropertyDto implements Arrayable
{
public function __construct(
public string $visibility,
public ParsedValueDto $value,
) {}
public function toArray(): array
{
return [
'visibility' => $this->visibility,
'value' => $this->value->toArray(),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|array>
*/
class ParseResultDto implements Arrayable
{
/**
* @param ParsedValueDto[] $dumps
*/
public function __construct(
public readonly ?string $source,
public readonly array $dumps,
) {}
public static function empty(): self
{
return new self(
source: null,
dumps: [],
);
}
public function toArray(): array
{
return [
'source' => $this->source,
'dumps' => collect($this->dumps)->toArray(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, int|array>
*/
readonly class ParsedArrayResultDto implements Arrayable
{
/**
* @param array<string, mixed> $items
*/
public function __construct(
public array $items,
public bool $numericallyIndexed,
) {}
public function toArray(): array
{
return [
'items' => collect($this->items)->toArray(),
'length' => count($this->items),
'numericallyIndexed' => $this->numericallyIndexed,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|null>
*/
readonly class ParsedClosureResultDto implements Arrayable
{
public function __construct(
public string $signature,
public ?string $className,
public ?string $thisReference,
) {}
public function toArray(): array
{
return [
'signature' => $this->signature,
'class' => $this->className,
'this' => $this->thisReference,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* @implements Arrayable<string, string|int|null|array>
*/
readonly class ParsedObjectResultDto implements Arrayable
{
/**
* @param ObjectPropertyDto[] $properties
*/
public function __construct(
public ?string $className,
public array $properties,
) {}
public function toArray(): array
{
return [
'class' => $this->className,
'properties' => collect($this->properties)->toArray(),
'propertiesCount' => count($this->properties),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
/**
* @implements Arrayable<string, string|float|int|bool|null|array>
*/
readonly class ParsedValueDto implements Arrayable
{
public function __construct(
public DumpValueTypeEnum $type,
public ParsedArrayResultDto|ParsedObjectResultDto|ParsedClosureResultDto|string|float|int|bool|null $value,
) {}
public function toArray(): array
{
return [
'type' => $this->type->value,
'value' => $this->value instanceof Arrayable ? $this->value->toArray() : $this->value,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums;
enum DumpValueTypeEnum: string
{
case Object = 'object';
case Array = 'array';
case String = 'string';
case Constant = 'constant';
case Uninitialized = 'uninitialized';
case Number = 'number';
case Closure = 'closure';
case Unknown = 'unknown';
}

View File

@@ -0,0 +1,384 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ObjectPropertyDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedArrayResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedClosureResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedObjectResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParsedValueDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParseResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\Enums\DumpValueTypeEnum;
class VarDumpParser
{
private const PATTERN_LARAVEL_COMMENT = '/<span[^>]*style=["\'][^"\']*color:\s*#A0A0A0[^"\']*["\'][^>]*>\s*\/\/\s*(.+?)<\/span>/s';
/**
* Parse raw HTML output containing one or more Symfony dumps
*/
public function parse(string $html): ParseResultDto
{
$html = $this->cleanHtml($html);
$dumpSections = $this->extractDumpSections($html);
if ($dumpSections === []) {
return ParseResultDto::empty();
}
$dumps = [];
foreach ($dumpSections as $dumpSection) {
$cleanHtml = $this->removeCommentFromDump($dumpSection);
$dumps[] = $this->parseValue(trim($cleanHtml));
}
return new ParseResultDto(
source: $this->extractComment($dumpSections[0]),
dumps: $dumps,
);
}
/**
* Clean HTML by removing unnecessary elements
*/
private function cleanHtml(string $html): string
{
// Remove style and script tags
$html = (string) preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html);
$html = (string) preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html);
// Remove ellipsis elements for simpler parsing
return (string) preg_replace('/<([a-z]+)\s+[^>]*class=(["\'])(?:[^"\'>]*\s)?sf-dump-ellipsis[^"\'>]*\2[^>]*>.*?<\/\1>/si', '', $html);
}
/**
* Extract individual dump sections from HTML
*
* @return array<array-key, string>
*/
private function extractDumpSections(string $html): array
{
preg_match_all('/<pre\b[^>]*>\K.*?(?=<\/pre>)/s', $html, $matches);
return $matches[0];
}
/**
* Extract Laravel file/line comment from dump output
*/
private function extractComment(string $html): ?string
{
if (preg_match(self::PATTERN_LARAVEL_COMMENT, $html, $match)) {
return trim($match[1]);
}
return null;
}
/**
* Remove Laravel comment from dump HTML
*/
private function removeCommentFromDump(string $html): string
{
return (string) preg_replace(self::PATTERN_LARAVEL_COMMENT, '', $html);
}
/**
* Detect the root type of a dump value
*/
private function detectRootType(string $html): DumpValueTypeEnum
{
if ($html === '""') {
return DumpValueTypeEnum::String;
}
if ($html === '[]') {
return DumpValueTypeEnum::Array;
}
// Array with size notation: array:3 [
if (preg_match('/^<span\b[^>]*class="?sf-dump-note[^>]*>\s*array:\d+/s', $html)) {
return DumpValueTypeEnum::Array;
}
// Closure (must check before object to avoid confusion with named objects)
if (preg_match('/^<span class="?sf-dump-note[^>]*>Closure\([^)]*\)<\/span>/s', $html)) {
return DumpValueTypeEnum::Closure;
}
// Named Objects (e.g. : ClassName {), or Closures.
if (preg_match('/^<span class="?sf-dump-note[^>]*>[^<]*<\/span>\s*\{/s', $html, $matches)) {
// If there are parenthesis in the matched portion (the beginning of the html) then it is a Closure.
// e.g. <span class="sf-dump-note sf-dump-ellipsization" title="Illuminate\Foundation\Application::environment(...$environments)"></span> {
// e.g. <span class=sf-dump-note>Illuminate\Foundation\Application::environment(...$environments)</span> {
return preg_match('/^.+\([^)]*\)/s', $matches[0])
? DumpValueTypeEnum::Closure
: DumpValueTypeEnum::Object;
}
// Runtime object: {<a...>
if (preg_match('/^\{<a class=sf-dump-ref/s', $html)) {
return DumpValueTypeEnum::Object;
}
// String value
if (preg_match('/^"<span\b[^>]*class=sf-dump-str\b/s', $html)) {
return DumpValueTypeEnum::String;
}
// Uninitialized property (must be before const check).
if (preg_match('/^<span [^>]* title="Uninitialized property">/s', $html)) {
return DumpValueTypeEnum::Uninitialized;
}
// Boolean or null constants
if (preg_match('/^<span\b[^>]*class=sf-dump-const\b/s', $html)) {
return DumpValueTypeEnum::Constant;
}
// Numeric value
if (preg_match('/^<span\b[^>]*class=sf-dump-num\b/s', $html)) {
return DumpValueTypeEnum::Number;
}
return DumpValueTypeEnum::Unknown;
}
/**
* Parse a value based on its detected type
*/
private function parseValue(string $html): ParsedValueDto
{
$dumpValueTypeEnum = $this->detectRootType($html);
$value = match ($dumpValueTypeEnum) {
DumpValueTypeEnum::Object => $this->parseObject($html),
DumpValueTypeEnum::Array => $this->parseArray($html),
DumpValueTypeEnum::String => $this->parseString($html),
DumpValueTypeEnum::Constant => $this->parseConst($html),
DumpValueTypeEnum::Number => $this->parseNumber($html),
DumpValueTypeEnum::Closure => $this->parseClosure($html),
DumpValueTypeEnum::Uninitialized => $this->parseUninitialized($html),
DumpValueTypeEnum::Unknown => null,
};
return new ParsedValueDto(type: $dumpValueTypeEnum, value: $value);
}
/**
* Parse object structure and properties
*/
private function parseObject(string $html): ParsedObjectResultDto
{
$className = $this->extractObjectClassName($html);
// Extract content between { and }
if (! preg_match('/\{<a class=sf-dump-ref[^>]*>[^<]+<\/a><samp[^>]+>(.+)<\/samp>}\n?$/s', $html, $match)) {
// Sometimes items are not listed when a certain depth is reached.
// e.g. Symfony\Component\Routing\CompiledRoute {#369 …8}
return new ParsedObjectResultDto(className: $className, properties: []);
}
$content = trim($match[1], "\n");
// Normalize indentation to simplify parsing
$content = $this->normalizeIndentation($content);
$properties = [];
// Match properties with visibility markers: +/-/# "propertyName": value
$pattern = '/[#\+\-]"?<span class=sf-dump-(public|protected|private)[^>]*>([^<]+)<\/span>"?:\s(.*?)(?=(?:\n[#\+\-](?:<|"<)|\Z))/s';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$visibility = $match[1];
$propertyName = trim($match[2]);
$propertyValueHtml = trim($match[3]);
$properties[$propertyName] = new ObjectPropertyDto(
visibility: $visibility,
value: $this->parseValue($propertyValueHtml),
);
}
return new ParsedObjectResultDto(className: $className, properties: $properties);
}
/**
* Parse array structure and items
*/
private function parseArray(string $html): ParsedArrayResultDto
{
if ($html === '[]') {
return new ParsedArrayResultDto(items: [], numericallyIndexed: true);
}
// Extract content within <samp> tags
if (! preg_match('/^<span\b[^>]*class=sf-dump-note[^>]*>\s*array:\d+\s*<\/span>\s*\[\s*<samp\b[^>]*>(.*?)<\/samp>]$/s', $html, $match)) {
// Sometimes items are not listed when a certain depth is reached.
// e.g. +methods: array:2 [ …2]
return new ParsedArrayResultDto(items: [], numericallyIndexed: true);
}
$content = trim($match[1], "\n");
// Normalize indentation
$content = $this->normalizeIndentation($content);
$items = [];
$isNumerical = true;
// Match key => value pairs (works for both indexed and associative)
$pattern = '/"?<span class=sf-dump-(key|index)[^>]*>([^<]+)<\/span>"?\s=>\s(.*?)(?=(?:\n"?<span class=sf-dump-(?:key|index))|\Z)/s';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$keyType = $match[1]; // 'key' or 'index'
$key = $match[2];
$valueHtml = trim($match[3]);
// If any key is not an index, the array is not numerically indexed
if ($keyType === 'key') {
$isNumerical = false;
}
$items[$key] = $this->parseValue($valueHtml);
}
return new ParsedArrayResultDto(items: $items, numericallyIndexed: $isNumerical);
}
/**
* Parse string primitive value
*/
private function parseString(string $html): string
{
if ($html === '""') {
return '';
}
if (preg_match('/<span class=sf-dump-str[^>]*>([^<]*)<\/span>/', $html, $match)) {
return html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
}
return '';
}
/**
* Parse constant value (true, false, null)
*/
private function parseConst(string $html): ?bool
{
if (preg_match('/<span class=sf-dump-const[^>]*>([^<]+)<\/span>/', $html, $match)) {
return match (strtolower(trim($match[1]))) {
'true' => true,
'false' => false,
default => null,
};
}
return null;
}
/**
* Parse numeric value (int or float)
*/
private function parseNumber(string $html): int|float
{
if (preg_match('/<span class=sf-dump-num[^>]*>([^<]+)<\/span>/', $html, $match)) {
$value = $match[1];
return str_contains($value, '.') ? (float) $value : (int) $value;
}
return 0;
}
/**
* Parse closure information
*/
private function parseClosure(string $html): ParsedClosureResultDto
{
$signature = 'CLosure()';
$className = null;
$thisReference = null;
// Extract signature from between span tags.
if (preg_match('/^<span[^>]*>([^<]+)/', $html, $match)) {
$signature = trim($match[1]);
}
// Extract signature from the title
elseif (preg_match('/^<span[^>]*title="([^"\n]+)/', $html, $match)) {
$signature = trim($match[1]);
}
// Extract class context
if (preg_match('/<span [^>]*class=sf-dump-meta\s*>class<\/span>:\s*"?<span [^>]*title="([^"\n]+)/', $html, $match)) {
$className = trim($match[1]);
}
// Extract this reference
if (preg_match('/<span [^>]*class=sf-dump-meta\s*>this<\/span>:\s*"?<span [^>]*title="([^"\n]+)/', $html, $match)) {
$thisReference = trim($match[1]);
}
return new ParsedClosureResultDto(
signature: $signature,
className: $className,
thisReference: $thisReference,
);
}
/**
* Parse uninitialized property annotation
*/
private function parseUninitialized(string $html): string
{
if (preg_match('/^<span[^>]+>([^<]+)<\/span>$/', $html, $match)) {
return $match[1];
}
return 'undefined';
}
/**
* Extract class name from object dump
*/
private function extractObjectClassName(string $html): ?string
{
// Check for title attribute (ellipsized class names)
if (preg_match('/^<span[^>]*title="([^"\n\s]+)/', $html, $match)) {
return trim($match[1]);
}
// Check for class name in sf-dump-note span
if (preg_match('/^<span class="?sf-dump-note[^>]*>([^<]+)<\/span>/s', $html, $match)) {
return html_entity_decode(strip_tags(trim($match[1])), ENT_QUOTES | ENT_HTML5);
}
// Runtime object (no explicit class name)
if (preg_match('/<a class=sf-dump-ref[^>]*>/', $html)) {
return '<runtime object>';
}
return null;
}
/**
* Normalize indentation by removing common leading whitespace
*/
private function normalizeIndentation(string $content): string
{
// Find the indentation of the first line
if (preg_match('/^(\s+)/s', $content, $match)) {
$indent = $match[1];
// Remove this indentation from all lines
return (string) preg_replace("/\n{$indent}/", "\n", ltrim($content));
}
return ltrim($content);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Responses;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\VarDumpParser;
class DumpAndDieResponse extends Response
{
public const DUMP_AND_DIE_STATUS_CODE = 999;
public function getStatusCode(): int
{
return self::DUMP_AND_DIE_STATUS_CODE;
}
/**
* @return array<string, string|array<array-key, mixed>>
*/
public function json($key = null, $default = null): array
{
[
'source' => $source,
'dumps' => $dumps,
] = resolve(VarDumpParser::class)->parse($this->response->getBody())->toArray();
return [
'id' => Str::uuid()->toString(),
'timestamp' => now()->format('Y-m-d H:i:s'),
'source' => $source,
'dumps' => $dumps,
];
}
}

View File

@@ -8,10 +8,12 @@ use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\TestWith;
use Ramsey\Uuid\Uuid;
use Sunchayn\Nimbus\Modules\Relay\Actions\RequestRelayAction;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
@@ -19,6 +21,9 @@ use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\DataTransferObjects\ParseResultDto;
use Sunchayn\Nimbus\Modules\Relay\Parsers\VarDumpParser\VarDumpParser;
use Sunchayn\Nimbus\Modules\Relay\Responses\DumpAndDieResponse;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
use Sunchayn\Nimbus\Tests\TestCase;
use Symfony\Component\HttpFoundation\ParameterBag;
@@ -26,6 +31,7 @@ use Symfony\Component\HttpFoundation\ParameterBag;
#[CoversClass(RequestRelayAction::class)]
#[CoversClass(RequestRelayData::class)]
#[CoversClass(RelayedRequestResponseData::class)]
#[CoversClass(DumpAndDieResponse::class)]
class RequestRelayActionFunctionalTest extends TestCase
{
private const ENDPOINT = 'https://localhost/api/test-endpoint';
@@ -304,6 +310,75 @@ class RequestRelayActionFunctionalTest extends TestCase
$this->assertEquals('test value with spaces', $response->cookies[0]->toArray()['value']['raw']);
}
public function test_it_parses_dump_and_die_responses(): void
{
// Arrange
$this->freezeTime();
$requestData = new RequestRelayData(
method: 'GET',
endpoint: self::ENDPOINT,
authorization: AuthorizationCredentials::none(),
headers: [
'Content-Type' => 'application/json',
'X-Custom-Header' => $customHeaderValue = uniqid(),
],
body: ['test' => 'data'],
cookies: new ParameterBag,
);
$uuid = fake()->uuid;
$stubSource = fake()->filePath();
$stubDumps = [
'type' => fake()->word(),
'value' => fake()->word(),
];
$parseResultDtoMock = Mockery::mock(ParseResultDto::class);
$varDumpParserMock = $this->mock(VarDumpParser::class);
$dumpHtml = '<script> Sfdump = window.Sfdump;</script><span>Hello World!</span>';
// Anticipate
Str::createUuidsUsing(fn () => Uuid::fromString($uuid));
Http::fake(fn (Request $request) => Http::response(
body: $dumpHtml,
status: 500,
headers: [],
));
$parseResultDtoMock
->shouldReceive('toArray')
->andReturn([
'source' => $stubSource,
'dumps' => $stubDumps,
])
->once();
$varDumpParserMock->shouldReceive('parse')->with($dumpHtml)->andReturn($parseResultDtoMock)->once();
// Act
$response = resolve(RequestRelayAction::class)->execute($requestData);
// Assert
$this->assertEquals(DumpAndDieResponse::DUMP_AND_DIE_STATUS_CODE, $response->statusCode);
$this->assertEquals(
[
'id' => $uuid,
'timestamp' => now()->format('Y-m-d H:i:s'),
'source' => $stubSource,
'dumps' => $stubDumps,
],
$response->body->body,
);
}
/*
* Helpers.
*/

File diff suppressed because one or more lines are too long

View File

@@ -12,38 +12,38 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: 'http://127.0.0.1:8000',
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
navigationTimeout: 60000,
screenshot: 'only-on-failure',
testIdAttribute: 'data-testid',
video: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 0,
/* Opt out of parallel tests on CI. */
workers: 8,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: "http://127.0.0.1:8000",
trace: "retain-on-failure",
ignoreHTTPSErrors: true,
navigationTimeout: 60000,
screenshot: "only-on-failure",
testIdAttribute: "data-testid",
video: "retain-on-failure",
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});

View File

@@ -15,6 +15,7 @@ REPO_URL="https://github.com/sunchayn/nimbus-dev.git"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="$SCRIPT_DIR/.workdir"
ROOT_DIR="$SCRIPT_DIR/../../"
# --------------------------------------
# HELPER FUNCTIONS
@@ -73,7 +74,7 @@ rm -rf "$TEMP_DIR"
cd "$TARGET_DIR"
# --------------------------------------
# DEPENDENCY INSTALLATION
# DEPENDENCY INSTALLATION (Inside Nimbus-Dev repository)
# --------------------------------------
# Install PHP dependencies
@@ -98,7 +99,7 @@ else
fi
# --------------------------------------
# ENVIRONMENT SETUP
# ENVIRONMENT SETUP (Inside Nimbus-Dev repository)
# --------------------------------------
ENV_FILE="$TARGET_DIR/.env"
@@ -107,7 +108,7 @@ rm -f "$ENV_FILE"
cp "$SCRIPT_DIR/.env.template" "$ENV_FILE"
# --------------------------------------
# APPLICATION BOOTSTRAP
# APPLICATION BOOTSTRAP (Inside Nimbus-Dev repository)
# --------------------------------------
echo "Bootstrapping application..."
@@ -116,7 +117,17 @@ echo "Bootstrapping application..."
touch database/database.sqlite
php artisan migrate --force
# Publish Nimbus-related frontend assets
php artisan vendor:publish --tag=nimbus-assets
# --------------------------------------
# Publish Nimbus-related frontend assets from the current branch.
# --------------------------------------
echo "Setup complete. Ready for E2E tests or further local usage."
cd "$ROOT_DIR"
echo "Building dev assets for Nimbus..."
npm install
npm run build:dev
# Publish Nimbus-related frontend assets
cp -a "$ROOT_DIR/resources/dist/." "$TARGET_DIR/public/vendor/nimbus/"
echo "Setup complete. Ready for E2E tests."

190
tests/E2E/tests/dd.spec.ts Normal file
View File

@@ -0,0 +1,190 @@
import { test, expect, Page } from "@playwright/test";
test("Dump and Die visualization sanity checklist", async ({ page }) => {
await page.goto("http://127.0.0.1:8000/demo/");
// helpers
const sendRequestWithIndex = async (index: string) => {
const value = page.getByTestId("kv-value").nth(3);
await value.fill(index);
await page.getByTestId("endpoint-input").click();
await page.waitForTimeout(300); // <- Wait for next tick.
await page.getByRole("button", { name: "Send ( )" }).click();
await expect(page.getByTestId("response-status-text")).toContainText(
"Dump & Die",
);
};
const response = () => page.getByLabel("Response");
const expectResponseHeader = async (pattern: RegExp | string) => {
await expect(response()).toContainText(pattern);
};
const expectDumpValue = async (
dumpIndex: number,
expected: RegExp | string,
) => {
const dump = page
.getByTestId(`dump-value-${dumpIndex}`)
.getByTestId("dump-value-content");
await expect(dump).toContainText(expected);
};
const expectExpandableInDump = (
dumpIndex: number,
label: RegExp | string,
) => {
const expectExpandableInDump = async (
dumpIndex: number,
label: string | RegExp,
) => {
// Narrow scope by first selecting the dump container
const dumpContainer = page.getByTestId(`dump-value-${dumpIndex}`);
// Use getByRole within container and match exact text if possible
const button = dumpContainer.getByRole("button", {
name: label,
exact: true,
});
await expect(button).toBeVisible();
};
};
// initial setup
await page.getByRole("button", { name: "dd" }).click();
await page.getByRole("button", { name: "GET /" }).click();
await page.getByRole("tab", { name: "Headers" }).click();
const headerKey = page.getByTestId("kv-key").nth(3);
await headerKey.fill("x-index");
await sendRequestWithIndex("0");
// status assertions
await expect(page.getByTestId("response-status-indicator")).toHaveClass(
/text-violet-600/,
);
// dump #0 (array root)
await expectExpandableInDump(0, "array: 4 items");
await expectExpandableInDump(0, /"users": array: \d+ items/);
await expectExpandableInDump(0, /"pagination": <runtime object>/);
await expect(response()).toContainText('"nullValue": null');
await expectExpandableInDump(0, /"callback": Closure/);
// dump #1 (request object)
await sendRequestWithIndex("1");
await expectExpandableInDump(0, /Illuminate\\Http\\Request:/);
await expectExpandableInDump(0, /\+attributes:/);
await expectExpandableInDump(0, /\+headers:/);
await expectResponseHeader(/#method:\s*"GET"/);
// Expand attributes and verify content exists
await page
.getByTestId("dump-value-0")
.getByRole("button", { name: /\+attributes:/ })
.click();
await expect(response()).toContainText("#parameters:");
// dump #2 (application container)
await sendRequestWithIndex("2");
await expectExpandableInDump(0, /Illuminate\\Foundation\\Application:/);
await expectExpandableInDump(0, /#bindings: array:/);
await expectExpandableInDump(0, /#instances: array:/);
await expectExpandableInDump(0, /#serviceProviders: array:/);
// Expand a leaf
await page
.getByTestId("dump-value-0")
.getByRole("button", { name: "#absoluteCachePathPrefixes:" })
.click();
await expect(
page.locator("#reka-collapsible-content-v-113"),
).toMatchAriaSnapshot(`- text: "0: \\"/\\" (1) 1: \\"\\\\\\" (1)"`);
// dump #3 (runtime object)
await sendRequestWithIndex("3");
await expectExpandableInDump(0, "<runtime object>: 6 properties");
await expect(response()).toContainText(/\+id:\s*\d+/);
await expect(response()).toContainText(
/\+name:\s*"Laravel Framework Book"/,
);
await expect(response()).toContainText(/\+price:\s*\d+\.\d+/);
await expect(response()).toContainText(/\+inStock:\s*true/);
// dump #4 (eloquent model)
await sendRequestWithIndex("4");
await expectExpandableInDump(0, /App\\Models\\User:/);
await expectExpandableInDump(0, /#attributes: array: \d+ items/);
await expectExpandableInDump(0, /#casts: array:/);
await expect(response()).toContainText(/#primaryKey:\s*"id"/);
// Expand attributes and carbon value
await page
.getByTestId("dump-value-0")
.getByRole("button", { name: /#attributes:/ })
.click();
await page
.getByTestId("dump-value-0")
.getByRole("button", { name: '"two_factor_confirmed_at":' })
.click();
await expect(response()).toContainText(/Illuminate\\Support\\Carbon/);
await expect(response()).toContainText(/#endOfTime:\s*false/);
// dump #5 (scalar dumps list)
await sendRequestWithIndex("5");
await expectDumpValue(0, /".+"\s+\(\d+\)/);
await expectDumpValue(1, /^\d+$/);
await expectDumpValue(2, "null");
await expectDumpValue(3, "true");
await expectDumpValue(4, "false");
// navigation
await page.getByTestId("next-dump-button").click();
await expectResponseHeader(/App\\Models\\User:/);
await page.getByTestId("next-dump-button").click();
await expectResponseHeader("<runtime object>: 6 properties");
await page.getByTestId("next-dump-button").click();
await expectResponseHeader(/Illuminate\\Foundation\\Application:/);
// deletion
await page.getByTestId("delete-dump-button").click();
await page.getByTestId("delete-dump-button").click();
await expect(page.getByLabel("Response")).toMatchAriaSnapshot(
`- text: 4 / 5`,
);
await expectResponseHeader(/Illuminate\\Http\\Request:/);
await page.getByTestId("next-dump-button").click();
await expectResponseHeader("array: 4 items");
await page.getByTestId("previous-dump-button").click();
await page.getByTestId("previous-dump-button").click();
await expectResponseHeader("<runtime object>: 6 properties");
});

View File

@@ -16,7 +16,8 @@ This guide covers everything you need to know about using Nimbus to test and exp
- [Route Explorer](#route-explorer)
- [Request Builder](#request-builder)
- [Response Viewer](#response-viewer)
- [Cookie Inspection](#cookie-inspection)
- [Cookie Inspection](#cookie-inspection)
- [Dump and Die Responses](#dump-and-die-responses)
- [Authentication](#authentication)
- [Session-Based Authentication](#session-based-authentication)
- [Bearer Tokens](#bearer-tokens)
@@ -172,7 +173,7 @@ The Response Viewer displays detailed information about API responses.
- Copy response body to clipboard.
- Pretty-printed JSON for readability.
### Cookie Inspection
#### Cookie Inspection
View and decrypt Laravel session cookies with ease.
@@ -186,6 +187,23 @@ The raw values as observed in the `Set-Cookie` headers from the response.
**Decrypted Cookies:**
Automatic decryption of Laravel encrypted cookies.
#### Dump and Die Responses
When a `dd()` response is detected. Nimbus will switch to a rich `dd` response viewer where you can navigate the dump(s) as a JSON object.
![DD Viewer](./assets/dd-viewer.png)
You can also see the file name and line shown under the tabs (e.g. `app/Http/Controllers/Demo/DumpAndDieController.php:140
`) which is the source that made these dumps.
_Note: dumps are only sticky for connectives `dd` responses, having a non-`dd` response will earse the history._
##### Dumps withing the same debug window
When keep sending `dd` responses, the previous values can still be accessed via pagination. You can always delete the unwanted ones.
![DD Viewer Pagination](./assets/dd-viewer-pagination.png)
---
## Authentication

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB