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:
4
.github/workflows/e2e-tests.yml
vendored
4
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/js-tests.yml
vendored
8
.github/workflows/js-tests.yml
vendored
@@ -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'
|
||||
|
||||
10
.github/workflows/php-tests.yml
vendored
10
.github/workflows/php-tests.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/types-check.yml
vendored
2
.github/workflows/types-check.yml
vendored
@@ -28,5 +28,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
- name: Run Type Checks
|
||||
run: npm run type:check
|
||||
|
||||
164
package.json
164
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }}": </span>
|
||||
<span v-else-if="keyName" :class="styles.numericalKey">
|
||||
{{ String(keyName) }}:
|
||||
</span>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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]: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,7 +35,7 @@ const props = defineProps({
|
||||
</span>
|
||||
</AppSidebarMenuButton>
|
||||
</AppCollapsibleTrigger>
|
||||
<AppCollapsibleContent>
|
||||
<AppCollapsibleContent class="overflow-hidden">
|
||||
<AppSidebarMenuSub>
|
||||
<slot />
|
||||
</AppSidebarMenuSub>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
resources/js/interfaces/generated/dump-value-types.ts
Normal file
18
resources/js/interfaces/generated/dump-value-types.ts
Normal 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',
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum STATUS {
|
||||
EMPTY = 'No request yet',
|
||||
PENDING = 'Pending',
|
||||
OTHER = 'Other',
|
||||
DUMP_AND_DIE = 'Dump & Die',
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('[]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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/';
|
||||
|
||||
41
src/IntellisenseProviders/DumpValueTypeIntellisense.php
Normal file
41
src/IntellisenseProviders/DumpValueTypeIntellisense.php
Normal 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);
|
||||
}
|
||||
}
|
||||
3
src/IntellisenseProviders/stubs/dump-value-types.ts.stub
Normal file
3
src/IntellisenseProviders/stubs/dump-value-types.ts.stub
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum DumpValueType {
|
||||
{{ content }}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
384
src/Modules/Relay/Parsers/VarDumpParser/VarDumpParser.php
Normal file
384
src/Modules/Relay/Parsers/VarDumpParser/VarDumpParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
src/Modules/Relay/Responses/DumpAndDieResponse.php
Normal file
35
src/Modules/Relay/Responses/DumpAndDieResponse.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
190
tests/E2E/tests/dd.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
BIN
wiki/user-guide/assets/dd-viewer-pagination.png
Normal file
BIN
wiki/user-guide/assets/dd-viewer-pagination.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
wiki/user-guide/assets/dd-viewer.png
Normal file
BIN
wiki/user-guide/assets/dd-viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
Reference in New Issue
Block a user