fix: preserve numeric override IDs before JSON cloning (#2142)

This commit is contained in:
Cervol Liu
2026-09-09 18:12:03 +08:00
committed by GitHub
parent 348db6baa0
commit 1700bfebe6
3 changed files with 99 additions and 4 deletions

View File

@@ -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(<SortableContext items={ids}>{null}</SortableContext>)
).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')
})
})

View File

@@ -14,6 +14,17 @@ let overrideConfigVersion = 0
// 与 profile.yaml 共用(见 runtimeConfigQueue.ts // 与 profile.yaml 共用(见 runtimeConfigQueue.ts
const overrideConfigWriteQueue = runtimeConfigWriteQueue 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<IOverrideConfig> { export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
if (force || !overrideConfig) { if (force || !overrideConfig) {
const seen = overrideConfigVersion const seen = overrideConfigVersion
@@ -24,12 +35,12 @@ export async function getOverrideConfig(force = false): Promise<IOverrideConfig>
} }
if (typeof overrideConfig !== 'object') overrideConfig = { items: [] } if (typeof overrideConfig !== 'object') overrideConfig = { items: [] }
if (!Array.isArray(overrideConfig.items)) 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<void> { export async function setOverrideConfig(config: IOverrideConfig): Promise<void> {
await overrideConfigWriteQueue.run(async () => { 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' }) await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' })
overrideConfig = nextConfig overrideConfig = nextConfig
overrideConfigVersion++ overrideConfigVersion++
@@ -47,7 +58,9 @@ export async function updateOverrideConfig(
throw new Error('Override config is invalid') throw new Error('Override config is invalid')
} }
if (!Array.isArray(currentConfig.items)) currentConfig.items = [] 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' }) await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' })
overrideConfig = nextConfig overrideConfig = nextConfig
overrideConfigVersion++ overrideConfigVersion++

View File

@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], include: ['src/**/*.test.{ts,tsx}', 'scripts/**/*.test.ts'],
environment: 'node' environment: 'node'
} }
}) })