From 1700bfebe6a634d9bc9e08e3e8522b3298226aa1 Mon Sep 17 00:00:00 2001 From: Cervol Liu Date: Wed, 9 Sep 2026 18:12:03 +0800 Subject: [PATCH] fix: preserve numeric override IDs before JSON cloning (#2142) --- src/main/config/override.test.tsx | 82 +++++++++++++++++++++++++++++++ src/main/config/override.ts | 19 +++++-- vitest.config.ts | 2 +- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 src/main/config/override.test.tsx diff --git a/src/main/config/override.test.tsx b/src/main/config/override.test.tsx new file mode 100644 index 00000000..9fb59e76 --- /dev/null +++ b/src/main/config/override.test.tsx @@ -0,0 +1,82 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import React from 'react' +import { renderToString } from 'react-dom/server' +import { SortableContext } from '@dnd-kit/sortable' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getOverride, + getOverrideConfig, + getOverrideItem, + setOverrideConfig, + updateOverrideItem +} from './override' + +let testDir = '' + +vi.mock('../utils/dirs', () => ({ + overrideConfigPath: () => join(testDir, 'override.yaml'), + overridePath: (id: string, ext: string) => join(testDir, `${id}.${ext}`) +})) +vi.mock('../utils/chromeRequest', () => ({ get: vi.fn() })) +vi.mock('./controledMihomo', () => ({ getControledMihomoConfig: vi.fn() })) + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'clash-party-override-test-')) +}) + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +function writeConfig(id: string): void { + writeFileSync( + join(testDir, 'override.yaml'), + `items:\n - id: ${id}\n name: Existing override\n type: local\n ext: js\n updated: 0\n` + ) +} + +describe('legacy numeric override IDs', () => { + it.each([ + ['.inf', 'Infinity'], + ['-.inf', '-Infinity'], + ['.nan', 'NaN'], + ['123456', '123456'], + ['19e86409178', 'Infinity'], + ['"19e86409178"', '19e86409178'] + ])('keeps %s usable after JSON transport', async (yamlId, expectedId) => { + writeConfig(yamlId) + const config = await getOverrideConfig(true) + // SortableContext probes object IDs with `in`; null would throw here. + const ids = JSON.parse(JSON.stringify(config)).items.map((item: IOverrideItem) => item.id) + expect(() => + renderToString({null}) + ).not.toThrow() + expect(config.items[0].id).toBe(expectedId) + expect((await getOverrideItem(expectedId))?.name).toBe('Existing override') + }) + + it('matches and persists the same ID during a queued update', async () => { + writeConfig('.inf') + const { items } = await getOverrideConfig(true) + await updateOverrideItem({ ...items[0], global: true }) + const saved = await getOverrideConfig(true) + expect(saved.items).toEqual([{ ...items[0], global: true }]) + expect(readFileSync(join(testDir, 'override.yaml'), 'utf8')).toContain('id: Infinity') + }) + + it('preserves access to the existing override file', async () => { + writeConfig('.inf') + writeFileSync(join(testDir, 'Infinity.js'), 'function main(config) { return config }') + const { items } = await getOverrideConfig(true) + expect(await getOverride(items[0].id, items[0].ext)).toContain('function main') + }) + + it('normalizes IDs before the setter JSON clone', async () => { + writeConfig('.inf') + const { items } = await getOverrideConfig(true) + await setOverrideConfig({ items: [{ ...items[0], id: Infinity as unknown as string }] }) + expect((await getOverrideConfig(true)).items[0].id).toBe('Infinity') + }) +}) diff --git a/src/main/config/override.ts b/src/main/config/override.ts index e4359309..27cce9d5 100644 --- a/src/main/config/override.ts +++ b/src/main/config/override.ts @@ -14,6 +14,17 @@ let overrideConfigVersion = 0 // 与 profile.yaml 共用(见 runtimeConfigQueue.ts) const overrideConfigWriteQueue = runtimeConfigWriteQueue +// Legacy YAML can contain numeric IDs (including .inf). Convert them before +// JSON cloning turns non-finite numbers into null, breaking the sortable UI. +function normalizeOverrideIds(config: IOverrideConfig): IOverrideConfig { + if (Array.isArray(config.items)) { + config.items = config.items.map((item) => + item && typeof item.id === 'number' ? { ...item, id: String(item.id) } : item + ) + } + return config +} + export async function getOverrideConfig(force = false): Promise { if (force || !overrideConfig) { const seen = overrideConfigVersion @@ -24,12 +35,12 @@ export async function getOverrideConfig(force = false): Promise } if (typeof overrideConfig !== 'object') overrideConfig = { items: [] } if (!Array.isArray(overrideConfig.items)) overrideConfig.items = [] - return JSON.parse(JSON.stringify(overrideConfig)) as IOverrideConfig + return JSON.parse(JSON.stringify(normalizeOverrideIds(overrideConfig))) as IOverrideConfig } export async function setOverrideConfig(config: IOverrideConfig): Promise { await overrideConfigWriteQueue.run(async () => { - const nextConfig = JSON.parse(JSON.stringify(config)) as IOverrideConfig + const nextConfig = JSON.parse(JSON.stringify(normalizeOverrideIds(config))) as IOverrideConfig await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) overrideConfig = nextConfig overrideConfigVersion++ @@ -47,7 +58,9 @@ export async function updateOverrideConfig( throw new Error('Override config is invalid') } if (!Array.isArray(currentConfig.items)) currentConfig.items = [] - const nextConfig = updater(JSON.parse(JSON.stringify(currentConfig)) as IOverrideConfig) + const nextConfig = updater( + JSON.parse(JSON.stringify(normalizeOverrideIds(currentConfig))) as IOverrideConfig + ) await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) overrideConfig = nextConfig overrideConfigVersion++ diff --git a/vitest.config.ts b/vitest.config.ts index 6045e2e7..a90ed3d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], + include: ['src/**/*.test.{ts,tsx}', 'scripts/**/*.test.ts'], environment: 'node' } })