Compare commits

..

5 Commits
master ... dev

Author SHA1 Message Date
fatedier
00c24e39bb test(web): add frontend unit test baseline (#5451) 2026-07-28 01:27:35 +08:00
fatedier
2175557d48 web: fix Vite config tsconfig includes (#5450) 2026-07-27 19:21:28 +08:00
fatedier
de4b483710 web: patch vulnerable transitive dependencies (#5449) 2026-07-27 14:57:58 +08:00
fatedier
2d63f6b1f9 web/frpc: fix visitor plugin validation for sudp (#5448) 2026-07-27 12:04:00 +08:00
barkure
40274d8c92 web/frpc: support virtual net visitor plugin (#5414) 2026-07-23 22:02:49 +08:00
24 changed files with 2019 additions and 47 deletions

View File

@@ -7,14 +7,15 @@ jobs:
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
name: Test and build web assets
command: make web-ci
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
name: Check Go formatting and build binaries
command: |
set -e
make env fmt
git diff --exit-code
make build
- run: make alltest
workflows:

View File

@@ -22,12 +22,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: Test and build web assets
run: make web-ci
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:

View File

@@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n
FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
all: env fmt web build
@@ -16,6 +16,9 @@ env:
web: frps-web frpc-web
web-ci:
cd web && npm ci && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
frps-web:
$(MAKE) -C web/frps build

View File

@@ -38,6 +38,7 @@ declare module 'vue' {
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
VisitorPluginSection: typeof import('./src/components/visitor-form/VisitorPluginSection.vue')['default']
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
}

View File

@@ -12,7 +12,7 @@
<!-- number -->
<el-input
v-else-if="type === 'number'"
:model-value="modelValue != null ? String(modelValue) : ''"
:model-value="numberDraft"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="handleNumberInput($event)"
@@ -112,7 +112,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import KeyValueEditor from './KeyValueEditor.vue'
import StringListEditor from './StringListEditor.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
@@ -154,16 +154,42 @@ const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const numberDraft = ref(
props.modelValue != null ? String(props.modelValue) : '',
)
const preserveNumberDraft = ref(false)
watch(
() => props.modelValue,
(value) => {
if (preserveNumberDraft.value && value == null) {
preserveNumberDraft.value = false
return
}
preserveNumberDraft.value = false
numberDraft.value = value != null ? String(value) : ''
},
)
const handleNumberInput = (val: string) => {
numberDraft.value = val
if (val === '') {
emit('update:modelValue', undefined)
return
}
// Keep a leading sign as an editing state so negative values can be typed
// naturally. The form value is updated once the input becomes a number.
if (val === '-' || val === '+') {
preserveNumberDraft.value = true
emit('update:modelValue', undefined)
return
}
const num = Number(val)
if (!isNaN(num)) {
let clamped = num
if (props.min != null && clamped < props.min) clamped = props.min
if (props.max != null && clamped > props.max) clamped = props.max
numberDraft.value = String(clamped)
emit('update:modelValue', clamped)
}
}

View File

@@ -12,7 +12,8 @@
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" />
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly"
:tip="bindPortTip" />
</div>
</ConfigSection>
</template>
@@ -36,6 +37,9 @@ const form = computed({
})
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
const bindPortTip = computed(() => form.value.type === 'sudp'
? ''
: 'Use -1 to skip the local listener when connections come from another visitor or plugin.')
</script>
<style scoped lang="scss">

View File

@@ -4,6 +4,11 @@
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
</ConfigSection>
<VisitorConnectionSection v-model="form" :readonly="readonly" />
<VisitorPluginSection
v-if="form.type === 'stcp' || form.type === 'xtcp'"
v-model="form"
:readonly="readonly"
/>
<VisitorTransportSection v-model="form" :readonly="readonly" />
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
</div>
@@ -15,6 +20,7 @@ import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import VisitorBaseSection from './VisitorBaseSection.vue'
import VisitorConnectionSection from './VisitorConnectionSection.vue'
import VisitorPluginSection from './VisitorPluginSection.vue'
import VisitorTransportSection from './VisitorTransportSection.vue'
import VisitorXtcpSection from './VisitorXtcpSection.vue'

View File

@@ -0,0 +1,52 @@
<template>
<ConfigSection title="Plugin" :readonly="readonly">
<div class="field-row two-col">
<ConfigField
label="Plugin Type"
type="select"
v-model="form.pluginType"
:options="pluginOptions"
placeholder="None"
:readonly="readonly"
/>
<ConfigField
v-if="form.pluginType === 'virtual_net'"
label="Destination IP"
type="text"
v-model="form.pluginDestinationIP"
prop="pluginDestinationIP"
placeholder="10.10.10.10"
tip="Destination address in the frp virtual network."
:readonly="readonly"
/>
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
import ConfigSection from '../ConfigSection.vue'
const pluginOptions = [
{ label: 'None', value: '' },
{ label: 'virtual_net', value: 'virtual_net' },
]
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -190,6 +190,16 @@ export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {
block.bindPort = form.bindPort
}
if (
form.pluginType === 'virtual_net' &&
(form.type === 'stcp' || form.type === 'xtcp')
) {
block.plugin = {
type: 'virtual_net',
destinationIP: form.pluginDestinationIP.trim(),
}
}
if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') {
block.protocol = form.protocol
@@ -448,6 +458,15 @@ export function storeVisitorToForm(
form.bindAddr = c.bindAddr || '127.0.0.1'
form.bindPort = c.bindPort
// Visitor plugin (only supported by stcp/xtcp; sudp runtime never uses it)
if (
c.plugin?.type === 'virtual_net' &&
(form.type === 'stcp' || form.type === 'xtcp')
) {
form.pluginType = 'virtual_net'
form.pluginDestinationIP = c.plugin.destinationIP || ''
}
// XTCP specific
form.protocol = c.protocol || 'quic'
form.keepTunnelOpen = c.keepTunnelOpen || false

View File

@@ -79,6 +79,10 @@ export interface VisitorFormData {
bindAddr: string
bindPort: number | undefined
// Visitor plugin
pluginType: '' | 'virtual_net'
pluginDestinationIP: string
// XTCP specific (XTCPVisitorConfig)
protocol: string
keepTunnelOpen: boolean
@@ -156,6 +160,9 @@ export function createDefaultVisitorForm(): VisitorFormData {
bindAddr: '127.0.0.1',
bindPort: undefined,
pluginType: '',
pluginDestinationIP: '',
protocol: 'quic',
keepTunnelOpen: false,
maxRetriesAnHour: undefined,

View File

@@ -107,6 +107,22 @@ const formRules: FormRules = {
trigger: 'blur',
},
],
pluginDestinationIP: [
{
validator: (_rule, value, callback) => {
if (
form.value.pluginType === 'virtual_net' &&
(form.value.type === 'stcp' || form.value.type === 'xtcp') &&
!value?.trim()
) {
callback(new Error('Destination IP is required for virtual_net'))
return
}
callback()
},
trigger: 'blur',
},
],
}
const goBack = () => {

View File

@@ -0,0 +1,73 @@
import { defineComponent } from 'vue'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ConfigField from '../src/components/ConfigField.vue'
const InputStub = defineComponent({
props: ['modelValue', 'disabled', 'placeholder', 'type'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue ?? \'\'" :disabled="disabled" :placeholder="placeholder" :type="type === \'password\' ? \'password\' : \'text\'" @input="$emit(\'update:modelValue\', $event.target.value)" />',
})
const FormItemStub = defineComponent({
template: '<div class="form-item-stub"><slot /></div>',
})
const stubs = {
'el-form-item': FormItemStub,
'el-input': InputStub,
'el-switch': defineComponent({
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: '<button :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)">switch</button>',
}),
KeyValueEditor: defineComponent({ template: '<div class="key-value-stub" />' }),
StringListEditor: defineComponent({ template: '<div class="string-list-stub" />' }),
PopoverMenu: defineComponent({ template: '<div class="popover-stub"><slot /></div>' }),
PopoverMenuItem: defineComponent({ template: '<div><slot /></div>' }),
}
describe('ConfigField', () => {
it('clamps numeric input and emits the public model update', async () => {
const wrapper = mount(ConfigField, {
props: { label: 'Port', type: 'number', modelValue: 100, min: 1, max: 65535 },
global: { stubs },
})
const input = wrapper.find('input')
await input.setValue('70000')
expect(wrapper.emitted('update:modelValue')).toContainEqual([65535])
expect((input.element as HTMLInputElement).value).toBe('65535')
})
it('keeps a leading sign as an editable draft until it becomes numeric', async () => {
const wrapper = mount(ConfigField, {
props: { label: 'Port', type: 'number', modelValue: 100 },
global: { stubs },
})
const input = wrapper.find('input')
await wrapper.setProps({ modelValue: 200 })
expect((input.element as HTMLInputElement).value).toBe('200')
await input.setValue('-')
expect(wrapper.emitted('update:modelValue')).toContainEqual([undefined])
await wrapper.setProps({ modelValue: undefined })
expect((input.element as HTMLInputElement).value).toBe('-')
})
it('renders an empty readonly field as a disabled placeholder', () => {
const wrapper = mount(ConfigField, {
props: { label: 'Secret', readonly: true, modelValue: '' },
global: { stubs },
})
const input = wrapper.find('input').element as HTMLInputElement
expect(wrapper.find('.config-field-label').text()).toBe('Secret')
expect(input.disabled).toBe(true)
expect(input.value).toBe('—')
})
})

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultProxyForm,
createDefaultVisitorForm,
} from '../src/types/proxy-form'
import {
formToStoreProxy,
formToStoreVisitor,
storeProxyToForm,
} from '../src/types/proxy-converters'
describe('proxy converters', () => {
it('serializes only configured proxy fields into the typed store block', () => {
const form = createDefaultProxyForm()
Object.assign(form, {
name: 'web',
type: 'http',
enabled: false,
localIP: '10.0.0.8',
localPort: 8080,
useEncryption: true,
customDomains: ['example.com', ''],
locations: ['/api', ''],
metadatas: [{ key: 'team', value: 'edge' }],
})
expect(formToStoreProxy(form)).toEqual({
name: 'web',
type: 'http',
http: {
enabled: false,
localIP: '10.0.0.8',
localPort: 8080,
transport: { useEncryption: true },
metadatas: { team: 'edge' },
customDomains: ['example.com'],
locations: ['/api'],
},
})
})
it('round-trips store values while applying form defaults', () => {
const form = storeProxyToForm({
name: 'tcp-proxy',
type: 'tcp',
tcp: {
localPort: 9000,
customDomains: 'unused-for-tcp',
enabled: false,
metadatas: { owner: 'platform' },
},
})
expect(form).toMatchObject({
name: 'tcp-proxy',
type: 'tcp',
enabled: false,
localIP: '127.0.0.1',
localPort: 9000,
metadatas: [{ key: 'owner', value: 'platform' }],
multiplexer: 'httpconnect',
})
})
it.each(['stcp', 'xtcp'] as const)(
'serializes virtual_net for %s visitors',
(type) => {
const form = createDefaultVisitorForm()
Object.assign(form, {
name: 'visitor',
type,
pluginType: 'virtual_net',
pluginDestinationIP: ' 192.0.2.10 ',
bindPort: 6000,
})
expect(formToStoreVisitor(form)[type]).toMatchObject({
plugin: { type: 'virtual_net', destinationIP: '192.0.2.10' },
bindPort: 6000,
})
},
)
it('does not serialize virtual_net for SUDP visitors', () => {
const form = createDefaultVisitorForm()
Object.assign(form, {
name: 'visitor',
type: 'sudp',
pluginType: 'virtual_net',
pluginDestinationIP: '192.0.2.10',
bindPort: 6000,
})
expect(formToStoreVisitor(form).sudp).toEqual({ bindPort: 6000 })
})
})

View File

@@ -6,5 +6,5 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.mts"]
}

View File

@@ -28,15 +28,10 @@ export default defineConfig({
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`,
},
},

View File

@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const router = vi.hoisted(() => ({ push: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => router }))
import StatCard from '../src/components/StatCard.vue'
const stubs = {
'el-card': defineComponent({
template: '<div class="el-card-stub" @click="$emit(\'click\', $event)"><slot /></div>',
emits: ['click'],
}),
'el-icon': defineComponent({ template: '<span class="el-icon-stub"><slot /></span>' }),
}
describe('StatCard', () => {
it('shows its content and does not navigate without a destination', async () => {
router.push.mockClear()
const wrapper = mount(StatCard, {
props: { label: 'Clients', value: 3, subtitle: 'active' },
global: { stubs },
})
expect(wrapper.find('.stat-value').text()).toBe('3')
expect(wrapper.find('.stat-label').text()).toBe('Clients')
expect(wrapper.find('.stat-subtitle').text()).toBe('active')
expect(wrapper.find('.arrow-icon').exists()).toBe(false)
await wrapper.find('.stat-card').trigger('click')
expect(router.push).not.toHaveBeenCalled()
})
it('navigates only when a destination is provided', async () => {
router.push.mockClear()
const wrapper = mount(StatCard, {
props: { label: 'Proxies', value: '12', type: 'proxies', to: '/proxies' },
global: { stubs },
})
await wrapper.find('.stat-card').trigger('click')
expect(router.push).toHaveBeenCalledWith('/proxies')
expect(wrapper.find('.arrow-icon').exists()).toBe(true)
})
})

View File

@@ -6,5 +6,5 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.mts"]
}

View File

@@ -28,15 +28,10 @@ export default defineConfig({
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`,
},
},

1540
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,14 @@
{
"name": "frp-web",
"private": true,
"workspaces": ["shared", "frpc", "frps"]
"workspaces": ["shared", "frpc", "frps"],
"scripts": {
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"devDependencies": {
"@vue/test-utils": "2.4.11",
"jsdom": "29.1.1",
"vitest": "4.1.10"
}
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ActionButton from '../components/ActionButton.vue'
describe('ActionButton', () => {
it('emits click events for an enabled button', async () => {
const wrapper = mount(ActionButton, { slots: { default: 'Save' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.text()).toBe('Save')
})
it('disables itself and shows loading text while loading', async () => {
const wrapper = mount(ActionButton, {
props: { loading: true, loadingText: 'Saving...' },
slots: { default: 'Save' },
})
await wrapper.trigger('click')
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.classes()).toContain('is-loading')
expect(wrapper.find('.spinner').exists()).toBe(true)
expect(wrapper.text()).toBe('Saving...')
expect(wrapper.emitted('click')).toBeUndefined()
})
})

25
web/test/setup.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest'
const capturedFetch = globalThis.fetch
describe('unit-test environment', () => {
it('blocks real network requests before test modules are evaluated', async () => {
await expect(capturedFetch('https://example.invalid/')).rejects.toThrow(
'Real network requests are disabled in unit tests',
)
})
it('allows a test to replace fetch explicitly', async () => {
const replacement = vi.fn(async () => new Response('ok'))
vi.stubGlobal('fetch', replacement)
await expect(fetch('/test-endpoint')).resolves.toBeInstanceOf(Response)
expect(replacement).toHaveBeenCalledWith('/test-endpoint')
})
it('restores the network guard after a test replacement', async () => {
await expect(fetch('/must-remain-isolated')).rejects.toThrow(
'Real network requests are disabled in unit tests',
)
})
})

25
web/test/setup.ts Normal file
View File

@@ -0,0 +1,25 @@
import { enableAutoUnmount } from '@vue/test-utils'
import { afterAll, afterEach, vi } from 'vitest'
const originalFetch = globalThis.fetch
const blockedFetch = vi.fn(async () => {
throw new Error('Real network requests are disabled in unit tests')
})
globalThis.fetch = blockedFetch as typeof fetch
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
globalThis.fetch = blockedFetch as typeof fetch
document.body.innerHTML = ''
})
// Vitest runs afterEach hooks in reverse registration order, so wrappers are
// unmounted before the cleanup hook above clears the DOM.
enableAutoUnmount(afterEach)
afterAll(() => {
globalThis.fetch = originalFetch
})

40
web/vitest.config.mts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
extends: './frpc/vite.config.mts',
root: './frpc',
test: {
name: 'frpc',
environment: 'jsdom',
css: true,
server: {
deps: { inline: ['element-plus', '@element-plus/icons-vue'] },
},
setupFiles: ['../test/setup.ts'],
include: [
'../test/**/*.test.ts',
'test/**/*.test.ts',
'../shared/**/*.test.ts',
],
},
},
{
extends: './frps/vite.config.mts',
root: './frps',
test: {
name: 'frps',
environment: 'jsdom',
css: true,
server: {
deps: { inline: ['element-plus', '@element-plus/icons-vue'] },
},
setupFiles: ['../test/setup.ts'],
include: ['test/**/*.test.ts'],
},
},
],
},
})