mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-19 12:43:39 +00:00
* feat(web-shell): add read-only Excel artifact previews * fix(vscode): refresh notices for Excel preview dependencies * fix(web-shell): bound Excel merge loading and preserve preview content * fix(web-shell): bound Excel preview work and preserve recovery * fix(web-shell): harden Excel preview values and recovery * fix(web-shell): include bundled ExcelJS dependency licenses * fix(web-shell): guard column expansion and complete ExcelJS notices * fix(web-shell): complete Excel color fallback and review guards * test(web-shell): follow artifact filename labels in Excel previews --------- Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
import ExcelJS from 'exceljs';
|
|
import JSZip from 'jszip';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
getExcelWorkbookInfo,
|
|
loadExcelWorkbook,
|
|
projectExcelSheet,
|
|
} from './excel-preview-parser';
|
|
|
|
async function preview(workbook: ExcelJS.Workbook) {
|
|
return loadExcelWorkbook(await workbook.xlsx.writeBuffer());
|
|
}
|
|
|
|
async function mergedWorkbook(rangesBySheet: string[][]) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
rangesBySheet.forEach((_ranges, index) => {
|
|
workbook.addWorksheet(`Sheet ${index}`).getCell('A1').value = 'Master';
|
|
});
|
|
const zip = await JSZip.loadAsync(await workbook.xlsx.writeBuffer());
|
|
for (const [index, ranges] of rangesBySheet.entries()) {
|
|
const path = `xl/worksheets/sheet${index + 1}.xml`;
|
|
const xml = await zip.file(path)!.async('string');
|
|
zip.file(
|
|
path,
|
|
xml.replace(
|
|
'</worksheet>',
|
|
`<mergeCells>${ranges.map((ref) => `<mergeCell ref="${ref}"/>`).join('')}</mergeCells></worksheet>`,
|
|
),
|
|
);
|
|
}
|
|
return zip.generateAsync({ type: 'arraybuffer', compression: 'DEFLATE' });
|
|
}
|
|
|
|
async function columnWorkbook(min: number, max: number) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('Columns').getCell('A1').value = 'Content';
|
|
const zip = await JSZip.loadAsync(await workbook.xlsx.writeBuffer());
|
|
const path = 'xl/worksheets/sheet1.xml';
|
|
const xml = await zip.file(path)!.async('string');
|
|
zip.file(
|
|
path,
|
|
xml.replace(
|
|
'<sheetData>',
|
|
`<cols><col min="${min}" max="${max}" width="10"/></cols><sheetData>`,
|
|
),
|
|
);
|
|
return zip.generateAsync({ type: 'arraybuffer', compression: 'DEFLATE' });
|
|
}
|
|
|
|
describe('Excel preview column loading limits', () => {
|
|
it.each([
|
|
[1, 16385],
|
|
[1, 4294967295],
|
|
[4294967295, 4294967295],
|
|
[0, 1],
|
|
[2, 1],
|
|
[1, 0],
|
|
])('rejects column range %i:%i before materialization', async (min, max) => {
|
|
const bytes = await columnWorkbook(min, max);
|
|
const Column = new ExcelJS.Workbook().addWorksheet('Probe').getColumn(1)
|
|
.constructor as { fromModel: () => unknown };
|
|
// Keep malicious fixtures safe even if the load guard regresses.
|
|
const expand = vi.spyOn(Column, 'fromModel').mockImplementation(() => {
|
|
throw new Error('Column materialization reached');
|
|
});
|
|
try {
|
|
await expect(loadExcelWorkbook(bytes)).rejects.toThrow(
|
|
'Column definitions are outside the worksheet',
|
|
);
|
|
expect(expand).not.toHaveBeenCalled();
|
|
} finally {
|
|
expand.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('accepts all 16,384 legal columns and preserves merges', async () => {
|
|
const workbook = await loadExcelWorkbook(await columnWorkbook(1, 16384));
|
|
const sheet = workbook.worksheets[0]!;
|
|
expect(sheet.getColumn(16384).width).toBe(10);
|
|
expect(projectExcelSheet(workbook, 0)).toMatchObject({
|
|
columns: 1,
|
|
rows: [[{ text: 'Content' }]],
|
|
});
|
|
const merged = await loadExcelWorkbook(await mergedWorkbook([['A1:B2']]));
|
|
expect(merged.worksheets[0]!.getCell('B2').master.address).toBe('A1');
|
|
});
|
|
});
|
|
|
|
describe('Excel preview merge loading limits', () => {
|
|
it.each<[string, string[][], string]>([
|
|
['one huge range', [['A1:XFD1048576']], 'Merged ranges are too large'],
|
|
[
|
|
'area just over the budget',
|
|
[['A1:A100001']],
|
|
'Merged ranges are too large',
|
|
],
|
|
[
|
|
'total area across worksheets',
|
|
[['A1:J6000'], ['A1:J6000']],
|
|
'Merged ranges are too large',
|
|
],
|
|
[
|
|
'too many ranges',
|
|
[Array.from({ length: 10001 }, (_, i) => `A${i + 1}:B${i + 1}`)],
|
|
'Too many merged ranges',
|
|
],
|
|
[
|
|
'total count across worksheets',
|
|
[
|
|
Array.from({ length: 6000 }, (_, i) => `A${i + 1}:B${i + 1}`),
|
|
Array.from({ length: 6000 }, (_, i) => `A${i + 1}:B${i + 1}`),
|
|
],
|
|
'Too many merged ranges',
|
|
],
|
|
['invalid address', [['A0:B2']], 'Invalid merged cell address'],
|
|
])('rejects %s before expansion', async (_name, ranges, message) => {
|
|
const bytes = await mergedWorkbook(ranges);
|
|
const prototype = Object.getPrototypeOf(
|
|
new ExcelJS.Workbook().addWorksheet('Probe'),
|
|
) as ExcelJS.Worksheet;
|
|
// Never allow an intentionally malicious fixture to allocate cells, even if
|
|
// the guard regresses. The expected limit error must precede expansion.
|
|
const expand = vi
|
|
.spyOn(prototype, 'mergeCellsWithoutStyle')
|
|
.mockImplementation(() => {});
|
|
try {
|
|
const error = await loadExcelWorkbook(bytes).then(
|
|
() => '',
|
|
(reason: Error) => reason.message,
|
|
);
|
|
expect(error).toContain(message);
|
|
expect(expand).toHaveBeenCalledTimes(
|
|
ranges.length > 1 ? ranges[0]!.length : 0,
|
|
);
|
|
} finally {
|
|
expand.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('accepts the exact area budget and isolates concurrent workbook budgets', async () => {
|
|
const bytes = await mergedWorkbook([['$A$1:$J$10000']]);
|
|
const books = await Promise.all([
|
|
loadExcelWorkbook(bytes),
|
|
loadExcelWorkbook(bytes),
|
|
]);
|
|
for (const book of books) {
|
|
expect(book.worksheets[0]!.getCell('J10000').master.address).toBe('A1');
|
|
expect(projectExcelSheet(book, 0)).toMatchObject({
|
|
columns: 10,
|
|
truncated: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('accepts the exact range count without doing quadratic merge work in the test', async () => {
|
|
const bytes = await mergedWorkbook([
|
|
Array.from({ length: 10000 }, (_, i) => `A${i + 1}:B${i + 1}`),
|
|
]);
|
|
const prototype = Object.getPrototypeOf(
|
|
new ExcelJS.Workbook().addWorksheet('Probe'),
|
|
) as ExcelJS.Worksheet;
|
|
const expand = vi
|
|
.spyOn(prototype, 'mergeCellsWithoutStyle')
|
|
.mockImplementation(() => {});
|
|
try {
|
|
await loadExcelWorkbook(bytes);
|
|
expect(expand).toHaveBeenCalledTimes(10000);
|
|
} finally {
|
|
expand.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Excel preview projection', () => {
|
|
it.each([false, true])(
|
|
'preserves display values with date1904=%s',
|
|
async (date1904) => {
|
|
const book = new ExcelJS.Workbook();
|
|
book.properties.date1904 = date1904;
|
|
const sheet = book.addWorksheet('Report');
|
|
sheet.addRow([1234.5, 0.125, 7, new Date('2026-10-08T00:00:00Z'), false]);
|
|
sheet.getCell('A1').numFmt = '#,##0.00';
|
|
sheet.getCell('B1').numFmt = '0.0%';
|
|
sheet.getCell('C1').numFmt = '00000';
|
|
sheet.getCell('D1').numFmt = 'yyyy-mm-dd';
|
|
sheet.addRow([
|
|
{ formula: '1-1', result: 0 },
|
|
{ formula: '1=2', result: false },
|
|
{ formula: 'SUM(A1:A2)' },
|
|
{ error: '#DIV/0!' },
|
|
{ richText: [{ text: 'Hello ' }, { text: 'world' }] },
|
|
]);
|
|
sheet.getCell('A3').value = {
|
|
text: '<script>alert(1)</script>',
|
|
hyperlink: 'javascript:alert(1)',
|
|
};
|
|
const parsed = projectExcelSheet(await preview(book), 0);
|
|
expect(parsed.rows[0]!.map((cell) => cell?.text)).toEqual([
|
|
'1,234.50',
|
|
'12.5%',
|
|
'00007',
|
|
'2026-10-08',
|
|
'FALSE',
|
|
]);
|
|
expect(parsed.rows[1]!.map((cell) => cell?.text)).toEqual([
|
|
'0',
|
|
'FALSE',
|
|
'',
|
|
'#DIV/0!',
|
|
'Hello world',
|
|
]);
|
|
expect(parsed.rows[1]![0]).toMatchObject({
|
|
formula: '1-1',
|
|
uncalculated: false,
|
|
});
|
|
expect(parsed.rows[1]![2]).toMatchObject({
|
|
formula: 'SUM(A1:A2)',
|
|
uncalculated: true,
|
|
});
|
|
expect(parsed.rows[2]![0]).toEqual({
|
|
text: '<script>alert(1)</script>',
|
|
style: {},
|
|
});
|
|
},
|
|
);
|
|
|
|
it('formats typed hyperlink values as plain strings after loading', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Links');
|
|
sheet.addRow([
|
|
{ richText: [{ text: 'Link ' }, { text: 'here' }] },
|
|
new Date('2026-10-08T00:00:00Z'),
|
|
1234.5,
|
|
true,
|
|
{ formula: 'HYPERLINK("https://example.test",1234.5)', result: 1234.5 },
|
|
]);
|
|
sheet.getCell('B1').numFmt = 'yyyy-mm-dd';
|
|
sheet.getCell('C1').numFmt = '#,##0.00';
|
|
sheet.getCell('E1').numFmt = '#,##0.00';
|
|
// OOXML hyperlink relationships preserve the underlying typed cell values.
|
|
const zip = await JSZip.loadAsync(await book.xlsx.writeBuffer());
|
|
const xmlPath = 'xl/worksheets/sheet1.xml';
|
|
const xml = await zip.file(xmlPath)!.async('string');
|
|
zip.file(
|
|
xmlPath,
|
|
xml.replace(
|
|
'</sheetData>',
|
|
'</sheetData><hyperlinks>' +
|
|
['A1', 'B1', 'C1', 'D1']
|
|
.map((ref) => `<hyperlink ref="${ref}" r:id="rId1"/>`)
|
|
.join('') +
|
|
'</hyperlinks>',
|
|
),
|
|
);
|
|
zip.file(
|
|
'xl/worksheets/_rels/sheet1.xml.rels',
|
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.test" TargetMode="External"/>' +
|
|
'</Relationships>',
|
|
);
|
|
const loaded = await loadExcelWorkbook(
|
|
await zip.generateAsync({ type: 'arraybuffer' }),
|
|
);
|
|
expect(loaded.worksheets[0]!.getCell('A1').hyperlink).toBe(
|
|
'https://example.test',
|
|
);
|
|
expect(
|
|
projectExcelSheet(loaded, 0).rows[0]!.map((cell) => cell?.text),
|
|
).toEqual(['Link here', '2026-10-08', '1,234.50', 'TRUE', '1,234.50']);
|
|
});
|
|
|
|
it('falls back both colors when an explicit color or fill is unsupported', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Colors');
|
|
sheet.addRow([
|
|
'Theme fill',
|
|
'Gradient',
|
|
'Pattern',
|
|
'Theme font',
|
|
'Indexed font',
|
|
]);
|
|
for (const column of ['A', 'B', 'C'])
|
|
sheet.getCell(`${column}1`).font = { color: { argb: 'FFFFFFFF' } };
|
|
sheet.getCell('A1').fill = {
|
|
type: 'pattern',
|
|
pattern: 'solid',
|
|
fgColor: { theme: 4 },
|
|
};
|
|
sheet.getCell('B1').fill = {
|
|
type: 'gradient',
|
|
gradient: 'angle',
|
|
degree: 0,
|
|
stops: [
|
|
{ position: 0, color: { argb: 'FF000000' } },
|
|
{ position: 1, color: { argb: 'FF1F4E79' } },
|
|
],
|
|
};
|
|
sheet.getCell('C1').fill = {
|
|
type: 'pattern',
|
|
pattern: 'lightGrid',
|
|
fgColor: { argb: 'FF1F4E79' },
|
|
};
|
|
for (const column of ['D', 'E']) {
|
|
sheet.getCell(`${column}1`).font = {
|
|
color: column === 'D' ? { theme: 0 } : { indexed: 2 },
|
|
};
|
|
sheet.getCell(`${column}1`).fill = {
|
|
type: 'pattern',
|
|
pattern: 'solid',
|
|
fgColor: { argb: 'FF1F4E79' },
|
|
};
|
|
}
|
|
const projected = projectExcelSheet(await preview(book), 0);
|
|
for (const cell of projected.rows[0]!) {
|
|
expect(cell?.style.color).toBeUndefined();
|
|
expect(cell?.style.backgroundColor).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it.each(['FF000000', 'FF000080'])(
|
|
'falls back an absent font color with solid fill %s',
|
|
async (argb) => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Missing foreground');
|
|
const cell = sheet.getCell('A1');
|
|
cell.value = 'Readable header';
|
|
cell.font = { bold: true };
|
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb } };
|
|
const loaded = await preview(book);
|
|
expect(loaded.worksheets[0]!.getCell('A1').font).toEqual({ bold: true });
|
|
const projected = projectExcelSheet(loaded, 0).rows[0]![0]!;
|
|
expect(projected.text).toBe('Readable header');
|
|
expect(projected.style.fontWeight).toBe('bold');
|
|
expect(projected.style.color).toBeUndefined();
|
|
expect(projected.style.backgroundColor).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it('keeps merged masters and basic styles without duplicating their values', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Merged');
|
|
sheet.mergeCells('B2:C3');
|
|
const cell = sheet.getCell('B2');
|
|
cell.value = 'Merged value';
|
|
cell.font = { bold: true, italic: true, color: { argb: 'FFFF0000' } };
|
|
cell.fill = {
|
|
type: 'pattern',
|
|
pattern: 'solid',
|
|
fgColor: { argb: 'FF00FF00' },
|
|
};
|
|
cell.alignment = { horizontal: 'center' };
|
|
book.addWorksheet('Empty');
|
|
const loaded = await preview(book);
|
|
const parsed = projectExcelSheet(loaded, 0);
|
|
expect(parsed.merges).toEqual([{ top: 1, left: 1, bottom: 2, right: 2 }]);
|
|
expect(parsed.rows[1]?.[1]).toMatchObject({
|
|
text: 'Merged value',
|
|
style: {
|
|
fontWeight: 'bold',
|
|
fontStyle: 'italic',
|
|
color: '#FF0000',
|
|
backgroundColor: '#00FF00',
|
|
textAlign: 'center',
|
|
},
|
|
});
|
|
expect(parsed.rows[2]?.[2]).toBeNull();
|
|
expect(projectExcelSheet(loaded, 1)).toMatchObject({
|
|
rows: [],
|
|
columns: 0,
|
|
truncated: false,
|
|
});
|
|
});
|
|
|
|
it('ignores peripheral empty formatting while preserving coordinates and merges', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Data');
|
|
for (let row = 1; row <= 2000; row++)
|
|
sheet.addRow([`Row ${row}`, row, row * 2]);
|
|
sheet.getCell('XFD1').font = { bold: true };
|
|
sheet.getCell('C5000').font = { bold: true };
|
|
const empty = book.addWorksheet('Only formatting');
|
|
empty.getCell('XFD1').fill = {
|
|
type: 'pattern',
|
|
pattern: 'solid',
|
|
fgColor: { argb: 'FFFF0000' },
|
|
};
|
|
const merged = book.addWorksheet('Merged extent');
|
|
merged.mergeCells('B2:E4');
|
|
merged.getCell('B2').value = 'Master';
|
|
const loaded = await preview(book);
|
|
expect(loaded.worksheets[0]!.columnCount).toBe(16384);
|
|
expect(loaded.worksheets[0]!.rowCount).toBe(5000);
|
|
const projected = projectExcelSheet(loaded, 0);
|
|
expect(projected).toMatchObject({ columns: 3, truncated: false });
|
|
expect(projected.rows).toHaveLength(2000);
|
|
expect(projected.rows.at(-1)?.[0]?.text).toBe('Row 2000');
|
|
expect(projectExcelSheet(loaded, 1)).toMatchObject({
|
|
columns: 0,
|
|
rows: [],
|
|
truncated: false,
|
|
});
|
|
const mergedProjection = projectExcelSheet(loaded, 2);
|
|
expect(mergedProjection.columns).toBe(5);
|
|
expect(mergedProjection.rows).toHaveLength(4);
|
|
expect(mergedProjection.rows[0]).toEqual([null, null, null, null, null]);
|
|
expect(mergedProjection.rows[1]?.[1]?.text).toBe('Master');
|
|
expect(mergedProjection.merges).toEqual([
|
|
{ top: 1, left: 1, bottom: 3, right: 4 },
|
|
]);
|
|
});
|
|
|
|
it('bounds work for a styled empty far column in every row without serializing the sheet', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Sparse');
|
|
for (let row = 1; row <= 200; row++) {
|
|
sheet.addRow([`Row ${row}`, row, row * 2]);
|
|
sheet.getCell(row, 16384).font = { bold: true };
|
|
}
|
|
const loaded = await preview(book);
|
|
const sparse = loaded.worksheets[0]!;
|
|
expect(sparse.columnCount).toBe(16384);
|
|
let cellAccesses = 0;
|
|
for (const row of sparse.getRows(1, 200)!) {
|
|
const internal = row as ExcelJS.Row & { _cells: ExcelJS.Cell[] };
|
|
internal._cells = new Proxy(internal._cells, {
|
|
has(target, property) {
|
|
cellAccesses++;
|
|
return Reflect.has(target, property);
|
|
},
|
|
get(target, property, receiver) {
|
|
if (typeof property === 'string' && /^\d+$/.test(property))
|
|
cellAccesses++;
|
|
return Reflect.get(target, property, receiver);
|
|
},
|
|
});
|
|
}
|
|
const model = vi.spyOn(sparse, 'model', 'get');
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
const result = projectExcelSheet(loaded, 0);
|
|
expect(result.rows).toHaveLength(200);
|
|
expect(result).toMatchObject({
|
|
columns: 3,
|
|
truncated: false,
|
|
merges: [],
|
|
});
|
|
}
|
|
expect(model).not.toHaveBeenCalled();
|
|
expect(cellAccesses).toBeLessThan(5000);
|
|
});
|
|
|
|
it('budgets all grid cells without independent sheet or column limits', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Large');
|
|
sheet.getCell('AY2001').value = 'outside preview';
|
|
for (let i = 1; i < 21; i++) book.addWorksheet(`Sheet ${i}`);
|
|
const loaded = await preview(book);
|
|
const metadata = getExcelWorkbookInfo(loaded);
|
|
expect(metadata.sheetNames).toHaveLength(21);
|
|
expect(projectExcelSheet(loaded, 20).name).toBe('Sheet 20');
|
|
const parsed = projectExcelSheet(loaded, 0);
|
|
expect(parsed.rows).toHaveLength(Math.floor(100_000 / 51));
|
|
expect(parsed).toMatchObject({ columns: 51, truncated: true });
|
|
expect(parsed.rows.at(-1)).toHaveLength(51);
|
|
expect(parsed.rows.length * parsed.columns).toBeLessThanOrEqual(100_000);
|
|
});
|
|
|
|
it('allows more than 2,000 rows, includes the exact budget and excludes the next row', () => {
|
|
const book = new ExcelJS.Workbook();
|
|
const sheet = book.addWorksheet('Narrow');
|
|
sheet.getCell('J10000').value = 'last included';
|
|
const exact = projectExcelSheet(book, 0);
|
|
expect(exact.rows).toHaveLength(10000);
|
|
expect(exact.rows.at(-1)?.[9]?.text).toBe('last included');
|
|
expect(exact.truncated).toBe(false);
|
|
sheet.getCell('A10001').value = 'excluded';
|
|
const over = projectExcelSheet(book, 0);
|
|
expect(over.rows).toHaveLength(10000);
|
|
expect(over.rows.length * over.columns).toBe(100_000);
|
|
expect(over.truncated).toBe(true);
|
|
});
|
|
|
|
it('does not project unrequested worksheets when loading metadata or another sheet', async () => {
|
|
const book = new ExcelJS.Workbook();
|
|
book.addWorksheet('First').getCell('A1').value = 'first value';
|
|
book.addWorksheet('Second').getCell('A1').value = 'second value';
|
|
const loaded = await preview(book);
|
|
const firstRead = vi.spyOn(loaded.worksheets[0]!, 'findRow');
|
|
const secondRead = vi.spyOn(loaded.worksheets[1]!, 'findRow');
|
|
expect(getExcelWorkbookInfo(loaded)).toEqual({
|
|
sheetNames: ['First', 'Second'],
|
|
});
|
|
expect(firstRead).not.toHaveBeenCalled();
|
|
expect(secondRead).not.toHaveBeenCalled();
|
|
expect(projectExcelSheet(loaded, 1).rows[0]?.[0]?.text).toBe(
|
|
'second value',
|
|
);
|
|
expect(secondRead).toHaveBeenCalled();
|
|
expect(firstRead).not.toHaveBeenCalled();
|
|
expect(projectExcelSheet(loaded, 0).rows[0]?.[0]?.text).toBe('first value');
|
|
});
|
|
|
|
it.each([-1, 1.5, 21, NaN])(
|
|
'rejects unavailable worksheet index %s',
|
|
(index) => {
|
|
const book = new ExcelJS.Workbook();
|
|
for (let i = 0; i < 21; i++) book.addWorksheet(`Sheet ${i}`);
|
|
expect(() => projectExcelSheet(book, index)).toThrow(RangeError);
|
|
},
|
|
);
|
|
|
|
it('rejects malformed files', async () => {
|
|
await expect(loadExcelWorkbook(new ArrayBuffer(10))).rejects.toThrow();
|
|
});
|
|
});
|