From d2c6eafc9eb826115cad3d12dcda502ecba1e6f0 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:24:31 +0800 Subject: [PATCH] chore(dev): add React profiling and focused A/B workflows --- eslint.config.ts | 34 ++ knip.json | 10 +- package.json | 7 +- pnpm-lock.yaml | 19 ++ scripts/react/README.md | 27 ++ scripts/react/ab.mjs | 526 +++++++++++++++++++++++++++++++ scripts/react/boundaries.tsx | 68 ++++ scripts/react/delay.ts | 36 +++ scripts/react/index.html | 5 + scripts/react/material.tsx | 16 + scripts/react/profiler.ts | 19 ++ scripts/react/scene.tsx | 124 ++++++++ scripts/react/state.ts | 13 + scripts/react/tsconfig.json | 12 + scripts/react/tsconfig.node.json | 8 + scripts/react/vite.mts | 32 ++ scripts/react/yaml.ts | 9 + vite.config.mts | 3 + 18 files changed, 965 insertions(+), 3 deletions(-) create mode 100644 scripts/react/README.md create mode 100644 scripts/react/ab.mjs create mode 100644 scripts/react/boundaries.tsx create mode 100644 scripts/react/delay.ts create mode 100644 scripts/react/index.html create mode 100644 scripts/react/material.tsx create mode 100644 scripts/react/profiler.ts create mode 100644 scripts/react/scene.tsx create mode 100644 scripts/react/state.ts create mode 100644 scripts/react/tsconfig.json create mode 100644 scripts/react/tsconfig.node.json create mode 100644 scripts/react/vite.mts create mode 100644 scripts/react/yaml.ts diff --git a/eslint.config.ts b/eslint.config.ts index 2f3aa9dc0..5a45de060 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -256,6 +256,40 @@ export default defineConfig([ }, }, }, + { + files: ['scripts/react/*.{mjs,mts,ts,tsx}'], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + projectService: false, + project: './scripts/react/tsconfig.json', + }, + }, + settings: { + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ + project: './scripts/react/tsconfig.json', + }), + ], + }, + }, + { + files: ['scripts/react/*.{mjs,mts}'], + languageOptions: { + parserOptions: { + projectService: false, + project: './scripts/react/tsconfig.node.json', + }, + }, + }, + { + files: ['scripts/react/{boundaries,material,scene}.tsx'], + // The isolated fixtures use full reloads and preserve aliased Hook names. + rules: { + 'react-refresh/only-export-components': 'off', + '@eslint-react/no-unnecessary-use-prefix': 'off', + }, + }, { files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'], rules: ipcBoundaryRules(), diff --git a/knip.json b/knip.json index 6ea552114..0901fcb18 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,14 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"], + "entry": [ + "src/polyfills/*.js!", + "scripts/check-contract-changes.mjs!", + "scripts/react/scene.tsx!", + "scripts/react/profiler.ts!", + "scripts/react/boundaries.tsx!", + "scripts/react/material.tsx!", + "scripts/react/yaml.ts!" + ], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "include": [ "files", diff --git a/package.json b/package.json index cb06f3002..6efbdaa30 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "perf:build": "node scripts/perf/build.mjs", "perf:run": "node scripts/perf/run.mjs", "perf:compare": "node scripts/perf/compare.mjs", - "perf:test": "node --test scripts/perf/report.test.mjs" + "perf:test": "node --test scripts/perf/report.test.mjs", + "dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev", + "react:ab": "node scripts/react/ab.mjs" }, "dependencies": { "@dnd-kit/collision": "^0.5.0", @@ -134,7 +136,8 @@ "vite-plugin-svgr": "^5.2.0", "vitest": "^5.0.1", "webdriverio": "10.1.0", - "ws": "8.22.1" + "ws": "8.22.1", + "playwright": "1.64.1" }, "lint-staged": { "*.{ts,tsx,js,mjs}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a38283819..96a9d3e73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: lint-staged: specifier: ^17.5.1 version: 17.5.1 + playwright: + specifier: 1.64.1 + version: 1.64.1 sass: specifier: ^1.104.1 version: 1.104.1 @@ -4317,6 +4320,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -9310,6 +9323,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss@8.5.28: dependencies: nanoid: 3.3.19 diff --git a/scripts/react/README.md b/scripts/react/README.md new file mode 100644 index 000000000..10d4ff11e --- /dev/null +++ b/scripts/react/README.md @@ -0,0 +1,27 @@ +# React diagnostics + +`pnpm dev:profile` runs the usual development launcher with a root React +Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns +copies of the latest 1,000 standard Profiler callback records; `.clear()` drops +them. Records contain timing and commit metadata, never business props. Normal +development and production builds do not load the collector. + +Install the browser explicitly with `pnpm exec playwright install chromium`. +Then run `pnpm react:ab --base --head `. Both Git trees run against +one installed dependency set and the same fixed DNS, delay-hook and proxy-tab +fixtures. The command never installs a browser or changes either checkout. +It retains source hashes, dependency identity, browser/environment information, +behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`. +Each version and mode has separate Vite caches and output. Three pairs alternate +order in ordinary development, Strict Mode and production. A behavior failure +returns a nonzero exit status. DNS stale serialization and same-value renders +are observations so a known baseline can be compared with its fix. + +The fixtures execute the actual selected components and hook. IPC, data, +editor and UI rendering boundaries are replaced, and outgoing network requests +are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is +observed at the Material Paper boundary. These are component results, not native +Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains. +Production React does not emit Profiler callbacks. Timing is diagnostic and has +no hard acceptance threshold; measure native release CPU/RSS separately and +calibrate A/A before claiming a performance gain. Keep raw reports out of Git. diff --git a/scripts/react/ab.mjs b/scripts/react/ab.mjs new file mode 100644 index 000000000..d28f3b85c --- /dev/null +++ b/scripts/react/ab.mjs @@ -0,0 +1,526 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + symlink, + writeFile, +} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +import react from '@vitejs/plugin-react' +import { chromium } from 'playwright' +import { build, createServer, preview } from 'vite' + +const { values } = parseArgs({ + options: { base: { type: 'string' }, head: { type: 'string' } }, +}) +if (!values.base || !values.head) + throw new Error('Usage: pnpm react:ab --base --head ') +const harness = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(harness, '../..') +const output = await mkdtemp('/tmp/cvr-react-ab-') +const dependencies = path.join(root, 'node_modules') +const frozenHarness = path.join(output, 'harness') +await cp(harness, frozenHarness, { + recursive: true, + filter: (file) => !path.basename(file).startsWith('tsconfig'), +}) +const files = [ + 'src/components/setting/mods/dns-viewer.tsx', + 'src/hooks/use-proxy-delay-state.ts', + 'src/components/home/proxy-tun-card.tsx', +] +const hash = (content) => createHash('sha256').update(content).digest('hex') +function git(...args) { + const result = spawnSync('git', args, { + cwd: root, + maxBuffer: 128 * 1024 * 1024, + }) + if (result.status !== 0) throw new Error(result.stderr.toString()) + return result.stdout +} +const manifest = { + environment: { + platform: process.platform, + architecture: process.arch, + node: process.version, + os: os.release(), + lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))), + }, + harness: Object.fromEntries( + await Promise.all( + (await readdir(frozenHarness)).map(async (file) => [ + file, + hash(await readFile(path.join(frozenHarness, file))), + ]), + ), + ), + installedLock: hash( + await readFile(path.join(dependencies, '.pnpm/lock.yaml')), + ), + sources: {}, + runs: [], +} +let browser +try { + // Playwright intentionally fails here if its explicitly installed browser is missing. + browser = await chromium.launch({ headless: true }) + manifest.environment.browser = browser.version() + for (const [variant, ref] of Object.entries({ + base: values.base, + head: values.head, + })) { + const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim() + const source = path.join(output, variant) + await mkdir(source) + const archive = git('archive', tree, 'src') + const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive }) + if (extracted.status !== 0) throw new Error(extracted.stderr.toString()) + await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true }) + await symlink(dependencies, path.join(source, 'node_modules'), 'dir') + manifest.sources[variant] = { + ref, + tree, + files: Object.fromEntries( + await Promise.all( + files.map(async (file) => [ + file, + hash(await readFile(path.join(source, file))), + ]), + ), + ), + } + } + for (const mode of ['development', 'strict', 'production']) { + const servers = {} + try { + for (const variant of ['base', 'head']) { + const source = path.join(output, variant) + const fixture = path.join(source, 'fixture') + const boundary = path.join(fixture, 'boundaries.tsx') + const aliases = [ + '@/hooks/use-clash', + '@/hooks/use-verge', + '@/hooks/use-profiles', + '@/hooks/use-system-state', + '@/hooks/use-system-proxy-state', + '@/services/states', + '@/services/cmds', + '@/services/mutate', + '@/services/notice-service', + '@/components/base', + '@/components/shared/proxy-control-switches', + 'react-i18next', + ].map((find) => ({ find, replacement: boundary })) + const config = { + configFile: false, + root: fixture, + cacheDir: path.join(output, 'cache', variant, mode), + plugins: [react()], + resolve: { + alias: [ + ...aliases, + { + find: '@/services/delay', + replacement: path.join(fixture, 'delay.ts'), + }, + { + find: /^@mui\/material$/, + replacement: path.join(fixture, 'material.tsx'), + }, + { + find: 'fixture-material', + replacement: path.join(dependencies, '@mui/material/index.mjs'), + }, + { find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') }, + { + find: 'fixture-yaml', + replacement: path.join( + dependencies, + 'js-yaml/dist/js-yaml.mjs', + ), + }, + { find: '@', replacement: path.join(source, 'src') }, + { + find: /^react(?=\/|$)/, + replacement: path.join(dependencies, 'react'), + }, + { + find: /^react-dom(?=\/|$)/, + replacement: path.join(dependencies, 'react-dom'), + }, + ], + dedupe: ['react', 'react-dom'], + }, + define: { OS_PLATFORM: JSON.stringify(process.platform) }, + server: { + host: '127.0.0.1', + port: 0, + hmr: false, + fs: { + allow: [ + output, + await realpath(output), + await realpath(dependencies), + ], + }, + }, + build: { + outDir: path.join(output, 'dist', variant, mode), + emptyOutDir: true, + }, + preview: { host: '127.0.0.1', port: 0 }, + } + if (mode === 'production') { + await build(config) + servers[variant] = await preview(config) + } else { + servers[variant] = await createServer(config) + await servers[variant].listen() + } + } + for (const [pair, order] of [ + ['base', 'head'], + ['head', 'base'], + ['base', 'head'], + ].entries()) { + for (const variant of order) { + const label = `${mode}-${pair + 1}-${variant}` + const context = await browser.newContext() + const errors = [] + const address = servers[variant].httpServer.address() + const origin = `http://127.0.0.1:${address.port}` + await context.route('**/*', (route) => { + if (new URL(route.request().url()).origin === origin) + return route.continue() + errors.push(`Unexpected network: ${route.request().url()}`) + return route.abort() + }) + const page = await context.newPage() + page.on('pageerror', (error) => errors.push(String(error))) + const result = { label, variant, mode, pair: pair + 1, errors } + try { + await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`) + await page.waitForSelector('#delay', { timeout: 60000 }) + await page.waitForFunction( + () => window.__REACT_AB__.read().listeners.length === 1, + ) + result.tabs = await tabs(page) + result.dns = await dns(page) + result.delay = await delay(page) + result.profile = await page.evaluate(() => { + const before = window.__CVR_REACT_PROFILER__.read() + window.__CVR_REACT_PROFILER__.clear() + return { before, after: window.__CVR_REACT_PROFILER__.read() } + }) + assert.equal(result.profile.after.length, 0) + result.collector = await page.evaluate(() => + window.__REACT_AB__.checkCollector(), + ) + assert.deepEqual(result.collector, { + length: 1000, + first: '5', + last: '1004', + copied: true, + cleared: true, + }) + if (mode !== 'production') + assert( + result.profile.before.length > 0 && + result.profile.before.length <= 1000, + ) + assert.deepEqual(errors, []) + assert.deepEqual( + await page.evaluate(() => + window.__REACT_AB__.fixture.notices.filter( + (notice) => notice.error, + ), + ), + [], + ) + result.ok = true + } catch (error) { + result.snapshot = await page + .evaluate(() => window.__REACT_AB__?.read()) + .catch(() => null) + result.ok = false + result.error = String(error) + throw error + } finally { + manifest.runs.push(result) + await writeFile( + path.join(output, `${label}.json`), + JSON.stringify(result, null, 2), + ) + await context.close() + console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`) + } + } + } + } finally { + for (const server of Object.values(servers)) { + if (server.close) await server.close() + else + await new Promise((resolve, reject) => + server.httpServer.close((error) => + error ? reject(error) : resolve(), + ), + ) + } + } + } +} finally { + await browser?.close() + await writeFile( + path.join(output, 'report.json'), + JSON.stringify(manifest, null, 2), + ) + console.log(`Evidence: ${output}`) +} + +async function settle(page) { + await page.waitForTimeout(100) +} +async function tabs(page) { + const buttons = page.locator('#tabs .MuiPaper-root') + assert.equal(await buttons.count(), 2) + const before = await page.evaluate( + () => window.__REACT_AB__.fixture.tabRenders.length, + ) + for (let index = 0; index < 5; index++) { + await page.evaluate(() => window.__REACT_AB__.update()) + await settle(page) + } + const unrelated = + (await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) - + before + await buttons.nth(1).click() + assert.equal( + await page.evaluate(() => + localStorage.getItem('clash-verge-proxy-active-tab'), + ), + 'tun', + ) + assert( + (await page.locator('#fixture-controls').textContent()).endsWith( + 'toggles.tunMode', + ), + ) + await page.evaluate(() => window.__REACT_AB__.update({ mounted: false })) + await settle(page) + assert( + (await page.locator('#fixture-controls').textContent()).endsWith( + 'toggles.tunMode', + ), + ) + await page.evaluate(() => window.__REACT_AB__.update({ mounted: true })) + await settle(page) + await buttons.nth(0).click() + assert.equal( + await page.evaluate(() => + localStorage.getItem('clash-verge-proxy-active-tab'), + ), + 'system', + ) + const beforeColor = await buttons + .nth(0) + .evaluate((element) => getComputedStyle(element).backgroundColor) + await page.evaluate(() => { + Object.assign(window.__REACT_AB__.fixture, { + indicator: true, + language: 'zh', + theme: 'dark', + }) + window.__REACT_AB__.fixture.verge.enable_tun_mode = true + window.__REACT_AB__.update() + }) + await settle(page) + const updated = await page.evaluate(() => + window.__REACT_AB__.fixture.tabRenders.slice(-2), + ) + assert(updated.every((tab) => tab.indicator && tab.label.startsWith('zh:'))) + const color = await buttons + .nth(0) + .evaluate((element) => getComputedStyle(element).backgroundColor) + assert.notEqual(color, beforeColor) + return { unrelated, updated, beforeColor, color } +} +async function dns(page) { + await page.evaluate(() => window.__REACT_AB__.open()) + const listen = page.locator('#dns input[placeholder=":53"]') + await page.waitForFunction( + () => + document.querySelector('#dns input[placeholder=":53"]')?.value === + ':5353', + ) + const edits = [] + for (const value of [':54', ':55', ':56']) { + await page.evaluate(() => { + window.__REACT_AB__.fixture.serialized.length = 0 + }) + await listen.fill(value) + await settle(page) + assert.equal(await listen.inputValue(), value) + edits.push({ + value, + serialized: await page.evaluate(() => + window.__REACT_AB__.fixture.serialized.map( + (config) => config.dns.listen, + ), + ), + }) + } + await page.evaluate(() => { + window.__REACT_AB__.fixture.serialized.length = 0 + }) + await listen.fill(':') + await listen.pressSequentially('567', { delay: 0 }) + await settle(page) + assert.equal(await listen.inputValue(), ':567') + edits.push({ + value: ':567', + rapid: true, + serialized: await page.evaluate(() => + window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen), + ), + }) + await page.locator('#save-dns').click() + await page.waitForFunction( + () => window.__REACT_AB__.fixture.saved.length === 1, + ) + assert.equal( + await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen), + ':567', + ) + await page.evaluate(() => window.__REACT_AB__.open()) + await page.waitForSelector('#dns') + await page + .getByRole('button', { name: /shared.actions.resetToDefault$/ }) + .click() + await settle(page) + assert.equal(await listen.inputValue(), ':53') + await page + .getByRole('button', { name: /shared.editorModes.advanced$/ }) + .click() + await settle(page) + const editor = page.locator('#fixture-monaco') + assert.match(await editor.inputValue(), /listen: ["']?:53/) + await editor.fill( + 'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n', + ) + await page + .getByRole('button', { name: /shared.editorModes.visualization$/ }) + .click() + await settle(page) + assert.equal(await listen.inputValue(), ':8053') + await page + .getByRole('button', { name: /shared.editorModes.advanced$/ }) + .click() + await settle(page) + await page.locator('#save-dns').click() + await page.waitForFunction( + () => window.__REACT_AB__.fixture.saved.length === 2, + ) + const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved) + assert.equal(saved[1].dns.listen, ':8053') + assert.equal(saved[1].hosts['example.test'], '127.0.0.1') + return { edits, saved } +} +async function delay(page) { + const updates = [ + { delay: 10, elapsed: 12, updatedAt: 100 }, + { delay: 10, elapsed: 12, updatedAt: 100 }, + { delay: 11, elapsed: 12, updatedAt: 100 }, + { delay: 11, elapsed: 13, updatedAt: 100 }, + { delay: 11, elapsed: 13, updatedAt: 101 }, + { delay: 11, updatedAt: 101 }, + { delay: -1, updatedAt: 102 }, + { delay: 0, updatedAt: 103 }, + { delay: 1000000, elapsed: 20, updatedAt: 104 }, + ] + const observations = [] + for (const update of updates) { + await page.evaluate((value) => { + window.__REACT_AB__.remember() + window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value) + }, update) + await settle(page) + const result = await page.evaluate(() => window.__REACT_AB__.read()) + assert.deepEqual(result.delay, update) + observations.push({ + update, + sameReference: result.sameReference, + childRenders: result.childRenders, + }) + } + await page.evaluate(() => window.__REACT_AB__.request()) + await settle(page) + assert.equal( + await page.evaluate(() => window.__REACT_AB__.read().delay.delay), + -2, + ) + await page.evaluate(() => + window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }), + ) + await settle(page) + assert.equal( + await page.evaluate(() => window.__REACT_AB__.read().delay.delay), + 0, + ) + await page.evaluate(() => window.__REACT_AB__.request()) + await settle(page) + assert.equal( + await page.evaluate(() => window.__REACT_AB__.read().delay.delay), + -2, + ) + await page.evaluate(() => + window.__REACT_AB__.delay.finish({ + delay: 1000000, + elapsed: 20, + updatedAt: 201, + }), + ) + await settle(page) + assert.deepEqual( + await page.evaluate(() => window.__REACT_AB__.read().delay), + { delay: 1000000, elapsed: 20, updatedAt: 201 }, + ) + await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' })) + await settle(page) + let state = await page.evaluate(() => window.__REACT_AB__.read()) + assert.deepEqual(state.listeners, ['Group 1:Node 2']) + assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 }) + await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' })) + await settle(page) + state = await page.evaluate(() => window.__REACT_AB__.read()) + assert.deepEqual(state.listeners, ['Group 2:Node 2']) + for (const name of ['DIRECT', 'Unknown']) { + await page.evaluate((name) => window.__REACT_AB__.update({ name }), name) + await settle(page) + const preset = await page.evaluate(() => window.__REACT_AB__.read()) + assert.deepEqual(preset.listeners, []) + assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 }) + } + await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' })) + await settle(page) + assert.deepEqual( + await page.evaluate(() => window.__REACT_AB__.read().listeners), + ['Group 2:Node 2'], + ) + await page.evaluate(() => window.__REACT_AB__.update({ mounted: false })) + await settle(page) + assert.deepEqual( + await page.evaluate(() => window.__REACT_AB__.read().listeners), + [], + ) + return observations +} diff --git a/scripts/react/boundaries.tsx b/scripts/react/boundaries.tsx new file mode 100644 index 000000000..4309e95c3 --- /dev/null +++ b/scripts/react/boundaries.tsx @@ -0,0 +1,68 @@ +import { Switch as MuiSwitch } from '@mui/material' + +import { fixture } from './state' + +const translations = { + en: (key: string) => `en:${key}`, + zh: (key: string) => `zh:${key}`, +} +export const useTranslation = () => ({ + t: translations[fixture.language as 'en' | 'zh'], +}) +export const useVerge = () => ({ verge: fixture.verge }) +export const useProfiles = () => ({ current: { uid: 'fixture' } }) +export const useClash = () => ({ + mutateClash: () => { + throw new Error('Unexpected mutateClash') + }, +}) +export const useSystemState = () => ({ isTunModeAvailable: fixture.available }) +export const useSystemProxyState = () => ({ indicator: fixture.indicator }) +export const useThemeMode = () => fixture.theme +export const getDnsConfigContent = async () => { + fixture.commands.push('getDnsConfigContent') + return fixture.initialDns +} +export const saveDnsConfig = async (config: any) => { + fixture.commands.push('saveDnsConfig') + fixture.saved.push(structuredClone(config)) +} +export const validateDnsConfig = async () => { + fixture.commands.push('validateDnsConfig') + return { status: 'valid' } +} +export const applyDnsConfig = () => { + throw new Error('Unexpected applyDnsConfig') +} +export const mutate = (fn: () => unknown) => fn() +export const showNotice = { + error: (...args: any[]) => fixture.notices.push({ error: args }), + success: (...args: any[]) => fixture.notices.push({ success: args }), +} +export const Switch = MuiSwitch +export function BaseDialog({ open, title, children, onClose, onOk }: any) { + return open ? ( +
+ {title} + + + {children} +
+ ) : null +} +export function MonacoEditor({ value, onChange }: any) { + return ( +