fix: tests fail on Windows

This commit is contained in:
tanzhenxin
2025-09-10 16:24:59 +08:00
parent 22dfefc9f1
commit e341e9ae37
4 changed files with 315 additions and 38 deletions

View File

@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { parse, stringify } from './yaml-parser.js';
describe('yaml-parser', () => {
describe('parse', () => {
it('should parse simple key-value pairs', () => {
const yaml = 'name: test\ndescription: A test config';
const result = parse(yaml);
expect(result).toEqual({
name: 'test',
description: 'A test config',
});
});
it('should parse arrays', () => {
const yaml = 'tools:\n - file\n - shell';
const result = parse(yaml);
expect(result).toEqual({
tools: ['file', 'shell'],
});
});
it('should parse nested objects', () => {
const yaml = 'modelConfig:\n temperature: 0.7\n maxTokens: 1000';
const result = parse(yaml);
expect(result).toEqual({
modelConfig: {
temperature: 0.7,
maxTokens: 1000,
},
});
});
});
describe('stringify', () => {
it('should stringify simple objects', () => {
const obj = { name: 'test', description: 'A test config' };
const result = stringify(obj);
expect(result).toBe('name: test\ndescription: A test config');
});
it('should stringify arrays', () => {
const obj = { tools: ['file', 'shell'] };
const result = stringify(obj);
expect(result).toBe('tools:\n - file\n - shell');
});
it('should stringify nested objects', () => {
const obj = {
modelConfig: {
temperature: 0.7,
maxTokens: 1000,
},
};
const result = stringify(obj);
expect(result).toBe(
'modelConfig:\n temperature: 0.7\n maxTokens: 1000',
);
});
describe('string escaping security', () => {
it('should properly escape strings with quotes', () => {
const obj = { key: 'value with "quotes"' };
const result = stringify(obj);
expect(result).toBe('key: "value with \\"quotes\\""');
});
it('should properly escape strings with backslashes', () => {
const obj = { key: 'value with \\ backslash' };
const result = stringify(obj);
expect(result).toBe('key: "value with \\\\ backslash"');
});
it('should properly escape strings with backslash-quote sequences', () => {
// This is the critical security test case
const obj = { key: 'value with \\" sequence' };
const result = stringify(obj);
// Should escape backslashes first, then quotes
expect(result).toBe('key: "value with \\\\\\" sequence"');
});
it('should handle complex escaping scenarios', () => {
const testCases = [
{
input: { path: 'C:\\Program Files\\"App"\\file.txt' },
expected: 'path: "C:\\\\Program Files\\\\\\"App\\"\\\\file.txt"',
},
{
input: { message: 'He said: \\"Hello\\"' },
expected: 'message: "He said: \\\\\\"Hello\\\\\\""',
},
{
input: { complex: 'Multiple \\\\ backslashes \\" and " quotes' },
expected:
'complex: "Multiple \\\\\\\\ backslashes \\\\\\" and \\" quotes"',
},
];
testCases.forEach(({ input, expected }) => {
const result = stringify(input);
expect(result).toBe(expected);
});
});
it('should maintain round-trip integrity for escaped strings', () => {
const testStrings = [
'simple string',
'string with "quotes"',
'string with \\ backslash',
'string with \\" sequence',
'path\\to\\"file".txt',
'He said: \\"Hello\\"',
'Multiple \\\\ backslashes \\" and " quotes',
];
testStrings.forEach((testString) => {
// Force quoting by adding a colon
const originalObj = { key: testString + ':' };
const yamlString = stringify(originalObj);
const parsedObj = parse(yamlString);
expect(parsedObj).toEqual(originalObj);
});
});
it('should not quote strings that do not need quoting', () => {
const obj = { key: 'simplevalue' };
const result = stringify(obj);
expect(result).toBe('key: simplevalue');
});
it('should quote strings with colons', () => {
const obj = { key: 'value:with:colons' };
const result = stringify(obj);
expect(result).toBe('key: "value:with:colons"');
});
it('should quote strings with hash symbols', () => {
const obj = { key: 'value#with#hash' };
const result = stringify(obj);
expect(result).toBe('key: "value#with#hash"');
});
it('should quote strings with leading/trailing whitespace', () => {
const obj = { key: ' value with spaces ' };
const result = stringify(obj);
expect(result).toBe('key: " value with spaces "');
});
});
describe('numeric string handling', () => {
it('should parse unquoted numeric values as numbers', () => {
const yaml = 'name: 11\ndescription: 333';
const result = parse(yaml);
expect(result).toEqual({
name: 11,
description: 333,
});
expect(typeof result['name']).toBe('number');
expect(typeof result['description']).toBe('number');
});
it('should parse quoted numeric values as strings', () => {
const yaml = 'name: "11"\ndescription: "333"';
const result = parse(yaml);
expect(result).toEqual({
name: '11',
description: '333',
});
expect(typeof result['name']).toBe('string');
expect(typeof result['description']).toBe('string');
});
it('should handle mixed numeric and string values', () => {
const yaml = 'name: "11"\nage: 25\ndescription: "333"';
const result = parse(yaml);
expect(result).toEqual({
name: '11',
age: 25,
description: '333',
});
expect(typeof result['name']).toBe('string');
expect(typeof result['age']).toBe('number');
expect(typeof result['description']).toBe('string');
});
});
});
});

View File

@@ -20,33 +20,36 @@
export function parse(yamlString: string): Record<string, unknown> {
const lines = yamlString
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
.filter((line) => line.trim() && !line.trim().startsWith('#'));
const result: Record<string, unknown> = {};
let currentKey = '';
let currentArray: string[] = [];
let currentArray: unknown[] = [];
let inArray = false;
let currentObject: Record<string, unknown> = {};
let inObject = false;
let objectKey = '';
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle array items
if (line.startsWith('- ')) {
if (line.startsWith(' - ')) {
if (!inArray) {
inArray = true;
currentArray = [];
}
currentArray.push(line.substring(2).trim());
const itemRaw = line.substring(4).trim();
currentArray.push(parseValue(itemRaw));
continue;
}
// End of array
if (inArray && !line.startsWith('- ')) {
if (inArray && !line.startsWith(' - ')) {
result[currentKey] = currentArray;
inArray = false;
currentArray = [];
currentKey = '';
}
// Handle nested object items (simple indentation)
@@ -62,6 +65,7 @@ export function parse(yamlString: string): Record<string, unknown> {
result[objectKey] = currentObject;
inObject = false;
currentObject = {};
objectKey = '';
}
// Handle key-value pairs
@@ -72,17 +76,25 @@ export function parse(yamlString: string): Record<string, unknown> {
if (value === '') {
// This might be the start of an object or array
currentKey = key.trim();
// Check if next lines are indented (object) or start with - (array)
continue;
// Look ahead to determine if this is an array or object
if (i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (nextLine.startsWith(' - ')) {
// Next line is an array item, so this will be handled in the next iteration
continue;
} else if (nextLine.startsWith(' ')) {
// Next line is indented, so this is an object
inObject = true;
objectKey = currentKey;
currentObject = {};
currentKey = '';
continue;
}
}
} else {
result[key.trim()] = parseValue(value);
}
} else if (currentKey && !inArray && !inObject) {
// This might be the start of an object
inObject = true;
objectKey = currentKey;
currentObject = {};
currentKey = '';
}
}
@@ -114,7 +126,7 @@ export function stringify(
if (Array.isArray(value)) {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${item}`);
lines.push(` - ${formatValue(item)}`);
}
} else if (typeof value === 'object' && value !== null) {
lines.push(`${key}:`);
@@ -140,6 +152,13 @@ function parseValue(value: string): unknown {
if (value === 'null') return null;
if (value === '') return '';
// Handle quoted strings
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
const unquoted = value.slice(1, -1);
// Unescape quotes and backslashes
return unquoted.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
// Try to parse as number
const num = Number(value);
if (!isNaN(num) && isFinite(num)) {
@@ -155,9 +174,16 @@ function parseValue(value: string): unknown {
*/
function formatValue(value: unknown): string {
if (typeof value === 'string') {
// Quote strings that might be ambiguous
if (value.includes(':') || value.includes('#') || value.trim() !== value) {
return `"${value.replace(/"/g, '\\"')}"`;
// Quote strings that might be ambiguous or contain special characters
if (
value.includes(':') ||
value.includes('#') ||
value.includes('"') ||
value.includes('\\') ||
value.trim() !== value
) {
// Escape backslashes THEN quotes
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return value;
}