修复:增强扫码与接口契约校验
This commit is contained in:
@@ -117,18 +117,187 @@ const expectedRequests = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRequests(source) {
|
const expectedReportExports = {
|
||||||
const requests = []
|
'src/api/report/palletInventory.js': ['listPalletInventory'],
|
||||||
const requestPattern = /return\s+request\(\{\s*url:\s*([^,\r\n]+),\s*method:\s*['"]([a-z]+)['"]/g
|
'src/api/report/filling.js': ['listFillingReport'],
|
||||||
let match
|
'src/api/report/shipment.js': ['listShipmentReport'],
|
||||||
|
'src/api/report/consumption.js': ['listConsumptionReport'],
|
||||||
|
'src/api/report/return.js': ['listReturnReport'],
|
||||||
|
'src/api/report/palletTrace.js': ['listPalletTrace']
|
||||||
|
}
|
||||||
|
|
||||||
while ((match = requestPattern.exec(source)) !== null) {
|
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
|
||||||
requests.push({
|
|
||||||
url: match[1].replace(/\s+/g, ' ').trim(),
|
function sanitizeSource(source) {
|
||||||
method: match[2]
|
const code = source.split('')
|
||||||
})
|
const structure = source.split('')
|
||||||
|
|
||||||
|
for (let index = 0; index < source.length;) {
|
||||||
|
const quote = source[index]
|
||||||
|
if (quote === '\'' || quote === '"' || quote === '`') {
|
||||||
|
let literalEnd = index + 1
|
||||||
|
while (literalEnd < source.length && source[literalEnd] !== quote) {
|
||||||
|
if (source[literalEnd] === '\\') {
|
||||||
|
literalEnd += 1
|
||||||
|
} else if (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') {
|
||||||
|
throw new Error('契约解析器不支持包含插值的模板字符串')
|
||||||
|
}
|
||||||
|
literalEnd += 1
|
||||||
|
}
|
||||||
|
if (literalEnd >= source.length) {
|
||||||
|
throw new Error('契约源码包含未闭合字符串')
|
||||||
|
}
|
||||||
|
structure.fill('\0', index, literalEnd + 1)
|
||||||
|
index = literalEnd + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineComment = source[index] === '/' && source[index + 1] === '/'
|
||||||
|
const blockComment = source[index] === '/' && source[index + 1] === '*'
|
||||||
|
if (!lineComment && !blockComment) {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let commentEnd = lineComment ? source.indexOf('\n', index + 2) : source.indexOf('*/', index + 2)
|
||||||
|
if (commentEnd === -1 && blockComment) {
|
||||||
|
throw new Error('契约源码包含未闭合注释')
|
||||||
|
}
|
||||||
|
commentEnd = commentEnd === -1 ? source.length : commentEnd + (blockComment ? 2 : 0)
|
||||||
|
while (index < commentEnd) {
|
||||||
|
if (source[index] !== '\n' && source[index] !== '\r') {
|
||||||
|
code[index] = ' '
|
||||||
|
structure[index] = ' '
|
||||||
|
}
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { code: code.join(''), structure: structure.join('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingDelimiter(structure, startIndex) {
|
||||||
|
const open = structure[startIndex]
|
||||||
|
const close = delimiterPairs[open]
|
||||||
|
let depth = 0
|
||||||
|
|
||||||
|
for (let index = startIndex; index < structure.length; index += 1) {
|
||||||
|
if (structure[index] === open) {
|
||||||
|
depth += 1
|
||||||
|
} else if (structure[index] === close && --depth === 0) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`契约源码包含未闭合的 ${open}${close}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelRanges(structure, separator) {
|
||||||
|
const ranges = []
|
||||||
|
const stack = []
|
||||||
|
let rangeStart = 0
|
||||||
|
|
||||||
|
for (let index = 0; index < structure.length; index += 1) {
|
||||||
|
const character = structure[index]
|
||||||
|
if (Object.hasOwn(delimiterPairs, character)) {
|
||||||
|
stack.push(character)
|
||||||
|
} else if (Object.values(delimiterPairs).includes(character)) {
|
||||||
|
const open = stack.pop()
|
||||||
|
if (delimiterPairs[open] !== character) {
|
||||||
|
throw new Error('request 对象属性括号不匹配')
|
||||||
|
}
|
||||||
|
} else if (character === separator && stack.length === 0) {
|
||||||
|
ranges.push([rangeStart, index])
|
||||||
|
rangeStart = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stack.length) {
|
||||||
|
throw new Error('request 对象属性括号不匹配')
|
||||||
|
}
|
||||||
|
ranges.push([rangeStart, structure.length])
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrlExpression(code, structure) {
|
||||||
|
let normalized = ''
|
||||||
|
for (let index = 0; index < code.length; index += 1) {
|
||||||
|
if (structure[index] === '\0') {
|
||||||
|
normalized += code[index]
|
||||||
|
} else if (/\s/.test(code[index])) {
|
||||||
|
continue
|
||||||
|
} else if (code[index] === '+') {
|
||||||
|
normalized = normalized.trimEnd() + ' + '
|
||||||
|
} else {
|
||||||
|
normalized += code[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequestArgument(argument, callIndex) {
|
||||||
|
let objectStart = 0
|
||||||
|
while (/\s/.test(argument.structure[objectStart])) {
|
||||||
|
objectStart += 1
|
||||||
|
}
|
||||||
|
if (argument.structure[objectStart] !== '{') {
|
||||||
|
throw new Error(`第 ${callIndex} 个 request 调用必须使用对象字面量`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectEnd = findClosingDelimiter(argument.structure, objectStart)
|
||||||
|
if (argument.structure.slice(objectEnd + 1).trim()) {
|
||||||
|
throw new Error(`第 ${callIndex} 个 request 调用必须只传入一个对象字面量`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyCode = argument.code.slice(objectStart + 1, objectEnd)
|
||||||
|
const bodyStructure = argument.structure.slice(objectStart + 1, objectEnd)
|
||||||
|
const values = { url: [], method: [] }
|
||||||
|
for (const [start, end] of splitTopLevelRanges(bodyStructure, ',')) {
|
||||||
|
const propertyCode = bodyCode.slice(start, end)
|
||||||
|
const propertyStructure = bodyStructure.slice(start, end)
|
||||||
|
if (!propertyCode.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyParts = splitTopLevelRanges(propertyStructure, ':')
|
||||||
|
const key = propertyCode.slice(...propertyParts[0]).trim()
|
||||||
|
if (propertyParts.length !== 2 || !/^[A-Za-z_$][\w$]*$/.test(key)) {
|
||||||
|
throw new Error(`第 ${callIndex} 个 request 对象只支持静态键值属性`)
|
||||||
|
}
|
||||||
|
if (key === 'url' || key === 'method') {
|
||||||
|
const [valueStart, valueEnd] = propertyParts[1]
|
||||||
|
values[key].push({
|
||||||
|
code: propertyCode.slice(valueStart, valueEnd),
|
||||||
|
structure: propertyStructure.slice(valueStart, valueEnd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.url.length !== 1 || values.method.length !== 1) {
|
||||||
|
throw new Error(`第 ${callIndex} 个 request 对象必须包含唯一 url 和 method`)
|
||||||
|
}
|
||||||
|
const methodMatch = /^\s*(['"])([a-z]+)\1\s*$/.exec(values.method[0].code)
|
||||||
|
if (!methodMatch) {
|
||||||
|
throw new Error(`第 ${callIndex} 个 request method 必须是静态小写字符串`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: normalizeUrlExpression(values.url[0].code, values.url[0].structure),
|
||||||
|
method: methodMatch[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRequests(source) {
|
||||||
|
const sanitized = sanitizeSource(source)
|
||||||
|
const requestPattern = /(?<![\w$])request\s*\(/g
|
||||||
|
const requests = []
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = requestPattern.exec(sanitized.structure)) !== null) {
|
||||||
|
const openParenthesis = match.index + match[0].lastIndexOf('(')
|
||||||
|
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
|
||||||
|
requests.push(parseRequestArgument({
|
||||||
|
code: sanitized.code.slice(openParenthesis + 1, closeParenthesis),
|
||||||
|
structure: sanitized.structure.slice(openParenthesis + 1, closeParenthesis)
|
||||||
|
}, requests.length + 1))
|
||||||
|
}
|
||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +309,61 @@ function hasExpectedRequest(expectedRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('契约解析器支持 url 与 method 换序及空白格式变化', () => {
|
||||||
|
const source = `
|
||||||
|
export function sample(query) {
|
||||||
|
return request(
|
||||||
|
{
|
||||||
|
method : 'get',
|
||||||
|
params: query,
|
||||||
|
url :
|
||||||
|
'/sample/' + query.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
assert.deepEqual(extractRequests(source), [
|
||||||
|
{ url: "'/sample/' + query.id", method: 'get' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('契约解析器拒绝无法静态核对的 request 调用', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => extractRequests('export function sample(config) { return request(config) }'),
|
||||||
|
/request 调用必须使用对象字面量/
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => extractRequests("return request({ url: '/sample', url: '/other', method: 'get' })"),
|
||||||
|
/唯一 url 和 method/
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => extractRequests("return request({ url: '/sample' })"),
|
||||||
|
/唯一 url 和 method/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('契约解析器忽略注释和字符串中的伪 request 调用', () => {
|
||||||
|
const source = `
|
||||||
|
// return request({ url: '/commented', method: 'delete' })
|
||||||
|
const example = "request(config)"
|
||||||
|
/*
|
||||||
|
request({ method: 'post', url: '/also-commented' })
|
||||||
|
*/
|
||||||
|
export function sample() {
|
||||||
|
return request({
|
||||||
|
url: '/sample',
|
||||||
|
/* 属性间注释不应干扰解析 */
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
assert.deepEqual(extractRequests(source), [
|
||||||
|
{ url: "'/sample'", method: 'get' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => {
|
test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => {
|
||||||
const { normalizeScanValue } = await import('../src/utils/palletScan.js')
|
const { normalizeScanValue } = await import('../src/utils/palletScan.js')
|
||||||
|
|
||||||
@@ -172,9 +396,6 @@ test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
|||||||
await t.test(relativePath, async () => {
|
await t.test(relativePath, async () => {
|
||||||
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
||||||
const actual = extractRequests(source)
|
const actual = extractRequests(source)
|
||||||
const requestCallCount = source.match(/\brequest\s*\(\{/g)?.length ?? 0
|
|
||||||
|
|
||||||
assert.equal(actual.length, requestCallCount, '存在未被契约解析器覆盖的 request 调用')
|
|
||||||
assert.deepEqual(actual, expected)
|
assert.deepEqual(actual, expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -219,7 +440,7 @@ test('六个报表 API 只提供 GET /list,不提供导出接口', async () =>
|
|||||||
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
||||||
const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1])
|
const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1])
|
||||||
assert.doesNotMatch(expected[0].url, /export/i)
|
assert.doesNotMatch(expected[0].url, /export/i)
|
||||||
assert.equal(exportedFunctionNames.some((name) => /export/i.test(name)), false)
|
assert.deepEqual(exportedFunctionNames, expectedReportExports[relativePath])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,6 +461,12 @@ test('扫码组件声明交互契约并保持触控尺寸', async () => {
|
|||||||
assert.match(source, /emit\(['"]scan['"]/)
|
assert.match(source, /emit\(['"]scan['"]/)
|
||||||
assert.match(source, /nextTick\(/)
|
assert.match(source, /nextTick\(/)
|
||||||
assert.match(source, /inputRef\.value\?\.focus\(\)/)
|
assert.match(source, /inputRef\.value\?\.focus\(\)/)
|
||||||
|
assert.match(source, /if \(!props\.disabled && !props\.loading\)/)
|
||||||
|
assert.match(source, /focusInput\(true\)/)
|
||||||
|
assert.match(source, /inputRef\.value\?\.select\(\)/)
|
||||||
|
assert.match(source, /nextTick\([\s\S]*?focus\(\)[\s\S]*?select\(\)/)
|
||||||
|
assert.match(source, /watch\(\s*\(\) => \[props\.loading, props\.disabled\]/)
|
||||||
|
assert.match(source, /if \(!loading && !disabled\)/)
|
||||||
assert.match(source, /min-height:\s*48px/)
|
assert.match(source, /min-height:\s*48px/)
|
||||||
assert.match(source, /width:\s*100%/)
|
assert.match(source, /width:\s*100%/)
|
||||||
assert.match(source, /is-loading/)
|
assert.match(source, /is-loading/)
|
||||||
|
|||||||
@@ -64,10 +64,13 @@ const errorMessage = ref('')
|
|||||||
let lastScanValue = ''
|
let lastScanValue = ''
|
||||||
let lastScanAt = 0
|
let lastScanAt = 0
|
||||||
|
|
||||||
function focusInput() {
|
function focusInput(selectValue = false) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!props.disabled) {
|
if (!props.disabled && !props.loading) {
|
||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
|
if (selectValue) {
|
||||||
|
inputRef.value?.select()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ function handleSubmit() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error.message
|
errorMessage.value = error.message
|
||||||
emit('invalid', error.message)
|
emit('invalid', error.message)
|
||||||
focusInput()
|
focusInput(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +121,14 @@ function handleSubmit() {
|
|||||||
|
|
||||||
onMounted(focusInput)
|
onMounted(focusInput)
|
||||||
|
|
||||||
watch(() => props.loading, (loading) => {
|
watch(
|
||||||
if (!loading) {
|
() => [props.loading, props.disabled],
|
||||||
focusInput()
|
([loading, disabled]) => {
|
||||||
|
if (!loading && !disabled) {
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user