feat(json-viewer): add auto-unescape functionality for escaped JSON strings
- Add auto-unescape toggle to handle escaped JSON strings like '{\"key\":\"value\"}'
- Implement smart unescaping logic that handles quotes, backslashes, and common escape sequences
- Support both quoted and unquoted escaped JSON formats
- Add real-time validation that works with auto-unescape toggle
- Improve UI layout with wider label for auto-unescape option
- Add comprehensive unit tests (15 test cases) covering all functionality
- Add E2E tests (6 test scenarios) for complete user workflow testing
- Update placeholder text to guide users about the new feature
- Add test-id for better E2E test reliability
This enhancement allows developers to easily prettify JSON from logs, API responses,
and other sources where JSON is commonly escaped in strings.
This commit is contained in:
parent
07eea0f484
commit
a831874e60
87
src/tools/json-viewer/json-viewer.e2e.spec.ts
Normal file
87
src/tools/json-viewer/json-viewer.e2e.spec.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Tool - JSON prettify and format', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/json-prettify');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Has correct title', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle('JSON prettify and format - IT Tools');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prettifies and formats valid JSON', async ({ page }) => {
|
||||||
|
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1,"c":{"z":3,"y":2}}');
|
||||||
|
|
||||||
|
const prettifiedJson = await page.getByTestId('area-content').innerText();
|
||||||
|
|
||||||
|
expect(prettifiedJson.trim()).toContain('"a": 1');
|
||||||
|
expect(prettifiedJson.trim()).toContain('"b": 2');
|
||||||
|
// Keys should be sorted alphabetically
|
||||||
|
expect(prettifiedJson.indexOf('"a"')).toBeLessThan(prettifiedJson.indexOf('"b"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles sort keys toggle', async ({ page }) => {
|
||||||
|
await page.getByTestId('json-prettify-input').fill('{"b":2,"a":1}');
|
||||||
|
|
||||||
|
// Disable sort keys
|
||||||
|
await page.locator('label:has-text("Sort keys")').locator('input[type="checkbox"]').click();
|
||||||
|
|
||||||
|
const unsortedJson = await page.getByTestId('area-content').innerText();
|
||||||
|
|
||||||
|
// Keys should maintain original order when sorting is disabled
|
||||||
|
expect(unsortedJson.indexOf('"b"')).toBeLessThan(unsortedJson.indexOf('"a"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles custom indent size', async ({ page }) => {
|
||||||
|
await page.getByTestId('json-prettify-input').fill('{"a":1}');
|
||||||
|
|
||||||
|
// Change indent size to 2
|
||||||
|
await page.locator('label:has-text("Indent size")').locator('input[type="number"]').fill('2');
|
||||||
|
|
||||||
|
const formattedJson = await page.getByTestId('area-content').innerText();
|
||||||
|
|
||||||
|
// Should use 2-space indentation
|
||||||
|
expect(formattedJson).toContain(' "a": 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-unescape functionality works with escaped JSON', async ({ page }) => {
|
||||||
|
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
|
||||||
|
|
||||||
|
await page.getByTestId('json-prettify-input').fill(escapedJson);
|
||||||
|
|
||||||
|
// Enable auto-unescape
|
||||||
|
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
|
||||||
|
|
||||||
|
const unescapedJson = await page.getByTestId('area-content').innerText();
|
||||||
|
|
||||||
|
expect(unescapedJson).toContain('"id": "123"');
|
||||||
|
expect(unescapedJson).toContain('"name": "test"');
|
||||||
|
expect(unescapedJson).not.toContain('\\"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-unescape toggle affects validation', async ({ page }) => {
|
||||||
|
const escapedJson = '"{\\\"valid\\\":\\\"json\\\"}"';
|
||||||
|
|
||||||
|
// First, paste escaped JSON without auto-unescape (should show validation error)
|
||||||
|
await page.getByTestId('json-prettify-input').fill(escapedJson);
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(page.locator('text=Provided JSON is not valid.')).toBeVisible();
|
||||||
|
|
||||||
|
// Enable auto-unescape
|
||||||
|
await page.locator('label:has-text("Auto-unescape")').locator('input[type="checkbox"]').click();
|
||||||
|
|
||||||
|
// Validation error should disappear
|
||||||
|
await expect(page.locator('text=Provided JSON is not valid.')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Output should be properly formatted
|
||||||
|
const formattedJson = await page.getByTestId('area-content').innerText();
|
||||||
|
expect(formattedJson).toContain('"valid": "json"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays helpful placeholder text', async ({ page }) => {
|
||||||
|
const textarea = page.getByTestId('json-prettify-input');
|
||||||
|
|
||||||
|
await expect(textarea).toHaveAttribute('placeholder', /auto-unescape.*escaped json/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -11,25 +11,61 @@ const inputElement = ref<HTMLElement>();
|
|||||||
const rawJson = useStorage('json-prettify:raw-json', '{"hello": "world", "foo": "bar"}');
|
const rawJson = useStorage('json-prettify:raw-json', '{"hello": "world", "foo": "bar"}');
|
||||||
const indentSize = useStorage('json-prettify:indent-size', 3);
|
const indentSize = useStorage('json-prettify:indent-size', 3);
|
||||||
const sortKeys = useStorage('json-prettify:sort-keys', true);
|
const sortKeys = useStorage('json-prettify:sort-keys', true);
|
||||||
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys }), ''));
|
const autoUnescape = useStorage('json-prettify:auto-unescape', false);
|
||||||
|
const cleanJson = computed(() => withDefaultOnError(() => formatJson({ rawJson, indentSize, sortKeys, autoUnescape }), ''));
|
||||||
|
|
||||||
const rawJsonValidation = useValidation({
|
const rawJsonValidation = useValidation({
|
||||||
source: rawJson,
|
source: rawJson,
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
validator: v => v === '' || JSON5.parse(v),
|
validator: (v: string) => {
|
||||||
|
if (v === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let jsonString = v;
|
||||||
|
if (autoUnescape.value) {
|
||||||
|
// Apply the same unescaping logic for validation
|
||||||
|
jsonString = jsonString.trim();
|
||||||
|
|
||||||
|
if ((jsonString.startsWith('"') && jsonString.endsWith('"'))
|
||||||
|
|| (jsonString.startsWith('\'') && jsonString.endsWith('\''))) {
|
||||||
|
jsonString = jsonString.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonString = jsonString
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\r/g, '\r')
|
||||||
|
.replace(/\\t/g, '\t')
|
||||||
|
.replace(/\\f/g, '\f')
|
||||||
|
.replace(/\\b/g, '\b')
|
||||||
|
.replace(/\\\//g, '/');
|
||||||
|
}
|
||||||
|
JSON5.parse(jsonString);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
message: 'Provided JSON is not valid.',
|
message: 'Provided JSON is not valid.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
watch: [autoUnescape],
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div style="flex: 0 0 100%">
|
<div style="flex: 0 0 100%">
|
||||||
<div style="margin: 0 auto; max-width: 600px" flex justify-center gap-3>
|
<div style="margin: 0 auto; max-width: 700px" flex flex-wrap justify-center gap-3>
|
||||||
<n-form-item label="Sort keys :" label-placement="left" label-width="100">
|
<n-form-item label="Sort keys :" label-placement="left" label-width="100">
|
||||||
<n-switch v-model:value="sortKeys" />
|
<n-switch v-model:value="sortKeys" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
<n-form-item label="Auto-unescape :" label-placement="left" label-width="130">
|
||||||
|
<n-switch v-model:value="autoUnescape" />
|
||||||
|
</n-form-item>
|
||||||
<n-form-item label="Indent size :" label-placement="left" label-width="100" :show-feedback="false">
|
<n-form-item label="Indent size :" label-placement="left" label-width="100" :show-feedback="false">
|
||||||
<n-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" />
|
<n-input-number v-model:value="indentSize" min="0" max="10" style="width: 100px" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@ -44,7 +80,7 @@ const rawJsonValidation = useValidation({
|
|||||||
<c-input-text
|
<c-input-text
|
||||||
ref="inputElement"
|
ref="inputElement"
|
||||||
v-model:value="rawJson"
|
v-model:value="rawJson"
|
||||||
placeholder="Paste your raw JSON here..."
|
placeholder="Paste your raw JSON here... Enable 'Auto-unescape' for escaped JSON strings"
|
||||||
rows="20"
|
rows="20"
|
||||||
multiline
|
multiline
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@ -52,6 +88,7 @@ const rawJsonValidation = useValidation({
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
monospace
|
monospace
|
||||||
|
test-id="json-prettify-input"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="Prettified version of your JSON">
|
<n-form-item label="Prettified version of your JSON">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { sortObjectKeys } from './json.models';
|
import { ref } from 'vue';
|
||||||
|
import { formatJson, sortObjectKeys } from './json.models';
|
||||||
|
|
||||||
describe('json models', () => {
|
describe('json models', () => {
|
||||||
describe('sortObjectKeys', () => {
|
describe('sortObjectKeys', () => {
|
||||||
@ -13,4 +14,105 @@ describe('json models', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatJson', () => {
|
||||||
|
const testJson = '{"b": 2, "a": 1}';
|
||||||
|
const expectedSorted = '{\n "a": 1,\n "b": 2\n}';
|
||||||
|
const expectedUnsorted = '{\n "b": 2,\n "a": 1\n}';
|
||||||
|
|
||||||
|
it('formats JSON with default options (sorted keys, 3 spaces)', () => {
|
||||||
|
const result = formatJson({ rawJson: testJson });
|
||||||
|
expect(result).toBe(expectedSorted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats JSON without sorting keys when sortKeys is false', () => {
|
||||||
|
const result = formatJson({ rawJson: testJson, sortKeys: false });
|
||||||
|
expect(result).toBe(expectedUnsorted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats JSON with custom indent size', () => {
|
||||||
|
const result = formatJson({ rawJson: testJson, indentSize: 2 });
|
||||||
|
const expected = '{\n "a": 1,\n "b": 2\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with reactive refs', () => {
|
||||||
|
const rawJsonRef = ref(testJson);
|
||||||
|
const sortKeysRef = ref(true);
|
||||||
|
const indentSizeRef = ref(3);
|
||||||
|
|
||||||
|
const result = formatJson({
|
||||||
|
rawJson: rawJsonRef,
|
||||||
|
sortKeys: sortKeysRef,
|
||||||
|
indentSize: indentSizeRef,
|
||||||
|
});
|
||||||
|
expect(result).toBe(expectedSorted);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoUnescape functionality', () => {
|
||||||
|
it('unescapes escaped JSON strings when autoUnescape is true', () => {
|
||||||
|
const escapedJson = '"{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}"';
|
||||||
|
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
|
||||||
|
const expected = '{\n "id": "123",\n "name": "test"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped JSON without outer quotes', () => {
|
||||||
|
const escapedJson = '{\\\"id\\\":\\\"123\\\",\\\"name\\\":\\\"test\\\"}';
|
||||||
|
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
|
||||||
|
const expected = '{\n "id": "123",\n "name": "test"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescapes various escape sequences', () => {
|
||||||
|
const escapedJson = '{\\\"text\\\":\\\"Hello\\\\\\\\World\\\",\\\"path\\\":\\\"/api\\\\/test\\\"}';
|
||||||
|
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
|
||||||
|
const expected = '{\n "path": "/api/test",\n "text": "Hello\\\\World"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single-quoted outer strings', () => {
|
||||||
|
const escapedJson = '\'{\\\"id\\\":\\\"123\\\"}\'';
|
||||||
|
const result = formatJson({ rawJson: escapedJson, autoUnescape: true, indentSize: 2 });
|
||||||
|
const expected = '{\n "id": "123"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes regular JSON normally when autoUnescape is false', () => {
|
||||||
|
const normalJson = '{"id":"123","name":"test"}';
|
||||||
|
const result = formatJson({ rawJson: normalJson, autoUnescape: false, indentSize: 2 });
|
||||||
|
const expected = '{\n "id": "123",\n "name": "test"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed escaped JSON gracefully', () => {
|
||||||
|
const malformedJson = '"{\\\"incomplete';
|
||||||
|
// Should fall back to original string and fail parsing
|
||||||
|
expect(() => formatJson({ rawJson: malformedJson, autoUnescape: true })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with complex nested objects', () => {
|
||||||
|
const complexEscaped = '"{\\\"users\\\":[{\\\"id\\\":\\\"1\\\",\\\"data\\\":{\\\"active\\\":true}}],\\\"meta\\\":{\\\"total\\\":1}}"';
|
||||||
|
const result = formatJson({ rawJson: complexEscaped, autoUnescape: true, indentSize: 2 });
|
||||||
|
const expected = '{\n "meta": {\n "total": 1\n },\n "users": [\n {\n "data": {\n "active": true\n },\n "id": "1"\n }\n ]\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with reactive autoUnescape ref', () => {
|
||||||
|
const escapedJson = '"{\\\"test\\\":\\\"value\\\"}"';
|
||||||
|
const autoUnescapeRef = ref(true);
|
||||||
|
const result = formatJson({ rawJson: escapedJson, autoUnescape: autoUnescapeRef, indentSize: 2 });
|
||||||
|
const expected = '{\n "test": "value"\n}';
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string input', () => {
|
||||||
|
expect(() => formatJson({ rawJson: '' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid JSON input', () => {
|
||||||
|
expect(() => formatJson({ rawJson: 'invalid json' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,16 +20,55 @@ function sortObjectKeys<T>(obj: T): T {
|
|||||||
}, {} as Record<string, unknown>) as T;
|
}, {} as Record<string, unknown>) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unescapeJson(jsonString: string): string {
|
||||||
|
try {
|
||||||
|
// First, try to handle double-escaped scenarios
|
||||||
|
let result = jsonString.trim();
|
||||||
|
|
||||||
|
// If the string starts and ends with quotes, and contains escaped quotes inside,
|
||||||
|
// it might be a JSON string that needs to be unescaped
|
||||||
|
if ((result.startsWith('"') && result.endsWith('"'))
|
||||||
|
|| (result.startsWith('\'') && result.endsWith('\''))) {
|
||||||
|
// Remove outer quotes first
|
||||||
|
result = result.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle common escape sequences
|
||||||
|
result = result
|
||||||
|
.replace(/\\"/g, '"') // Unescape quotes
|
||||||
|
.replace(/\\\\/g, '\\') // Unescape backslashes (do this after quotes!)
|
||||||
|
.replace(/\\n/g, '\n') // Unescape newlines
|
||||||
|
.replace(/\\r/g, '\r') // Unescape carriage returns
|
||||||
|
.replace(/\\t/g, '\t') // Unescape tabs
|
||||||
|
.replace(/\\f/g, '\f') // Unescape form feeds
|
||||||
|
.replace(/\\b/g, '\b') // Unescape backspaces
|
||||||
|
.replace(/\\\//g, '/'); // Unescape forward slashes
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return jsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatJson({
|
function formatJson({
|
||||||
rawJson,
|
rawJson,
|
||||||
sortKeys = true,
|
sortKeys = true,
|
||||||
indentSize = 3,
|
indentSize = 3,
|
||||||
|
autoUnescape = false,
|
||||||
}: {
|
}: {
|
||||||
rawJson: MaybeRef<string>
|
rawJson: MaybeRef<string>
|
||||||
sortKeys?: MaybeRef<boolean>
|
sortKeys?: MaybeRef<boolean>
|
||||||
indentSize?: MaybeRef<number>
|
indentSize?: MaybeRef<number>
|
||||||
|
autoUnescape?: MaybeRef<boolean>
|
||||||
}) {
|
}) {
|
||||||
const parsedObject = JSON5.parse(get(rawJson));
|
let jsonString = get(rawJson);
|
||||||
|
|
||||||
|
if (get(autoUnescape)) {
|
||||||
|
jsonString = unescapeJson(jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedObject = JSON5.parse(jsonString);
|
||||||
|
|
||||||
return JSON.stringify(get(sortKeys) ? sortObjectKeys(parsedObject) : parsedObject, null, get(indentSize));
|
return JSON.stringify(get(sortKeys) ? sortObjectKeys(parsedObject) : parsedObject, null, get(indentSize));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user