Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9850a2e49 | ||
|
|
8097943e3a | ||
|
|
d5a9a72fa6 | ||
|
|
b7918be6f3 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
5501c9f1a5 | ||
|
|
eefe6f05e1 | ||
|
|
6598292b1b | ||
|
|
dea637228d | ||
|
|
a09467f1eb | ||
|
|
34feb348af | ||
|
|
6b1a1a6201 | ||
|
|
9f60cdeef1 | ||
|
|
258caa5906 | ||
|
|
a0d59a79aa | ||
|
|
c7148f3ebb | ||
|
|
ef29b48a55 | ||
|
|
5c33ea526e | ||
|
|
6d3b54b25f | ||
|
|
867bb44586 | ||
|
|
4bbd42cc25 | ||
|
|
fd0c3350f3 | ||
|
|
2cc24de505 | ||
|
|
6575301a3a | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
93a2071837 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
46ae1c50fe | ||
|
|
5de36d783a | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
ea7c66b190 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
3027337a34 | ||
|
|
d0ad33034e | ||
|
|
5f13876e6e | ||
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
307c9e797b | ||
|
|
116117e987 | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
6572010922 | ||
|
|
6738d0f29e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
5d25a10223 | ||
|
|
2bd6b297b0 | ||
|
|
0d5c6d92e3 | ||
|
|
88f7ea5679 | ||
|
|
c0b562cc07 | ||
|
|
70d58648c8 | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef |
@@ -0,0 +1,285 @@
|
||||
'use strict';
|
||||
|
||||
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
|
||||
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
|
||||
|
||||
const PRODUCT_GROUPS = [
|
||||
{
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
|
||||
},
|
||||
{
|
||||
primary: 'typefield',
|
||||
backup: 'wxianfeng',
|
||||
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
|
||||
},
|
||||
{
|
||||
primary: 'haofeng0705',
|
||||
backup: 'typefield',
|
||||
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
|
||||
},
|
||||
];
|
||||
|
||||
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
id: 'security',
|
||||
label: '登录、认证、权限、安全',
|
||||
primary: 'hlzjsong',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'internal/auth/',
|
||||
'internal/keychain/',
|
||||
'internal/audit/',
|
||||
'internal/pat/',
|
||||
'internal/security/',
|
||||
'internal/safety/',
|
||||
'pkg/edition/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
label: 'CI、测试、发布、安装',
|
||||
primary: 'haofeng0705',
|
||||
backup: 'wxianfeng',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
path.startsWith('.github/') ||
|
||||
path.startsWith('scripts/release/') ||
|
||||
path.startsWith('scripts/policy/') ||
|
||||
path.startsWith('scripts/dev/') ||
|
||||
path.startsWith('scripts/install') ||
|
||||
path.startsWith('Formula/') ||
|
||||
path.startsWith('build/') ||
|
||||
path.startsWith('internal/upgrade/') ||
|
||||
path.startsWith('internal/app/upgrade') ||
|
||||
path.startsWith('test/') ||
|
||||
path.startsWith('verify/') ||
|
||||
path.startsWith('.workflow/') ||
|
||||
path === 'coverage.txt' ||
|
||||
path === 'coverage-base.txt' ||
|
||||
path === '.goreleaser.yaml' ||
|
||||
path === 'package.json' ||
|
||||
path === 'package-lock.json' ||
|
||||
path === 'docs/releasing.md',
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
label: 'DWS 架构、公共内核',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'cmd/',
|
||||
'internal/apiclient/',
|
||||
'internal/app/',
|
||||
'internal/cli/',
|
||||
'internal/cobracmd/',
|
||||
'internal/corecmd/',
|
||||
'internal/errors/',
|
||||
'internal/executor/',
|
||||
'internal/generator/',
|
||||
'internal/i18n/',
|
||||
'internal/interfacesnapshot/',
|
||||
'internal/jsonutil/',
|
||||
'internal/localio/',
|
||||
'internal/logging/',
|
||||
'internal/output/',
|
||||
'internal/pipeline/',
|
||||
'internal/plugin/',
|
||||
'internal/profilectx/',
|
||||
'internal/registry/',
|
||||
'internal/syncdata/',
|
||||
'internal/testseam/',
|
||||
'internal/transport/',
|
||||
'pkg/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'compatibility',
|
||||
label: '兼容性',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
|
||||
path.includes('schema_compat'),
|
||||
},
|
||||
];
|
||||
|
||||
function productMatches(path, product) {
|
||||
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
|
||||
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
|
||||
}
|
||||
|
||||
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
|
||||
group.products.map((product) => ({
|
||||
id: `product:${product}`,
|
||||
label: `产品:${product}`,
|
||||
primary: group.primary,
|
||||
backup: group.backup,
|
||||
requiresSecondary: false,
|
||||
matches: (path) => productMatches(path, product),
|
||||
})),
|
||||
);
|
||||
|
||||
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
|
||||
|
||||
function normalizedPaths(file) {
|
||||
return [file?.filename, file?.previous_filename]
|
||||
.filter((path) => typeof path === 'string' && path !== '')
|
||||
.map((path) => path.toLowerCase());
|
||||
}
|
||||
|
||||
function compareStats(left, right) {
|
||||
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
|
||||
}
|
||||
|
||||
function classifyFiles(files) {
|
||||
const counts = new Map();
|
||||
for (const file of files || []) {
|
||||
const matchingModules = new Set();
|
||||
for (const path of normalizedPaths(file)) {
|
||||
const matches = ALL_MODULES.filter((module) => module.matches(path));
|
||||
const securityOrDelivery = matches.filter(
|
||||
(module) => module.id === 'security' || module.id === 'delivery',
|
||||
);
|
||||
const effectiveMatches = securityOrDelivery.length > 0
|
||||
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
|
||||
: matches;
|
||||
for (const match of effectiveMatches) {
|
||||
matchingModules.add(match.id);
|
||||
}
|
||||
if (
|
||||
effectiveMatches.length === 0 &&
|
||||
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
|
||||
) {
|
||||
matchingModules.add('architecture');
|
||||
}
|
||||
}
|
||||
for (const moduleID of matchingModules) {
|
||||
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([id, files]) => {
|
||||
const index = ALL_MODULES.findIndex((module) => module.id === id);
|
||||
return {module: {...ALL_MODULES[index], order: index}, files};
|
||||
})
|
||||
.sort(compareStats);
|
||||
}
|
||||
|
||||
function chooseModuleReviewer(module, unavailable) {
|
||||
return [module.primary, module.backup].find(
|
||||
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
|
||||
);
|
||||
}
|
||||
|
||||
function addReviewer(reviewers, reviewer) {
|
||||
if (reviewer && !reviewers.includes(reviewer)) {
|
||||
reviewers.push(reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
|
||||
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
|
||||
const candidates = [];
|
||||
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
|
||||
if (
|
||||
eligible.has(reviewer.toLowerCase()) &&
|
||||
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
|
||||
) {
|
||||
candidates.push(reviewer);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers,
|
||||
satisfiedReviewers = [],
|
||||
requestReviewer,
|
||||
onFailure = () => {},
|
||||
}) {
|
||||
const alreadySatisfied = new Set(
|
||||
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
|
||||
);
|
||||
const satisfied = new Set();
|
||||
const requested = [];
|
||||
|
||||
for (const reviewer of candidates) {
|
||||
if (satisfied.size >= requiredReviewers) {
|
||||
break;
|
||||
}
|
||||
const normalizedReviewer = reviewer.toLowerCase();
|
||||
if (alreadySatisfied.has(normalizedReviewer)) {
|
||||
satisfied.add(normalizedReviewer);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldContinue = await requestReviewer(reviewer);
|
||||
if (shouldContinue === false) {
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
|
||||
}
|
||||
requested.push(reviewer);
|
||||
satisfied.add(normalizedReviewer);
|
||||
} catch (error) {
|
||||
onFailure(reviewer, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
|
||||
}
|
||||
|
||||
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
|
||||
const modules = classifyFiles(files);
|
||||
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
|
||||
const reviewers = [];
|
||||
const primaryModule = modules[0];
|
||||
|
||||
if (!primaryModule) {
|
||||
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
|
||||
}
|
||||
|
||||
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
|
||||
|
||||
const requiresSecondary =
|
||||
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
|
||||
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
|
||||
if (requiresSecondary) {
|
||||
addReviewer(
|
||||
reviewers,
|
||||
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
|
||||
);
|
||||
}
|
||||
|
||||
for (const reviewer of fallbackReviewers) {
|
||||
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
|
||||
break;
|
||||
}
|
||||
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
|
||||
addReviewer(reviewers, reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
|
||||
reviewers,
|
||||
requiredReviewers: requiresSecondary ? 2 : 1,
|
||||
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REVIEWER_POOL,
|
||||
classifyFiles,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./reviewer-routing');
|
||||
|
||||
function route(files, author = 'author', latestPusher = author) {
|
||||
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 1);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['.github/workflows/ci.yml']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.equal(result.reason, 'cross_or_sensitive');
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/auth/login.go'], 'hlzjsong');
|
||||
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/upgrade/downloader.go']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['pkg/edition/edition.go']);
|
||||
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/leaf_dispatch.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['docs/unknown-area.md']);
|
||||
assert.deepEqual(result.reviewers, []);
|
||||
assert.equal(result.reason, 'unknown_paths');
|
||||
}
|
||||
|
||||
async function testSingleReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['wxianfeng'],
|
||||
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 1,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('cannot request primary'), {status: 422});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 1);
|
||||
}
|
||||
|
||||
async function testTwoReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['haofeng0705', 'wxianfeng'],
|
||||
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 2,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('temporary failure'), {status: 503});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 2);
|
||||
}
|
||||
|
||||
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates: ['wxianfeng', 'typefield'],
|
||||
requiredReviewers: 1,
|
||||
satisfiedReviewers: ['typefield'],
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng']);
|
||||
assert.deepEqual(result.requested, ['wxianfeng']);
|
||||
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
testSingleReviewerFallback(),
|
||||
testTwoReviewerFallback(),
|
||||
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
|
||||
])
|
||||
.then(() => console.log('reviewer routing policy tests passed'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+32
-17
@@ -433,6 +433,10 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
- name: Test reviewer routing policy
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
needs: lint
|
||||
@@ -1366,36 +1370,47 @@ jobs:
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
|
||||
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
. ./scripts/release/release-lib.sh
|
||||
stable_ref=""
|
||||
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
|
||||
release_is_stable_version "$tag" || continue
|
||||
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
stable_ref="$tag"
|
||||
break
|
||||
done
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
printf '%s\n' \
|
||||
"COMPATIBILITY_BASE_REF=$base_ref" \
|
||||
"COMPATIBILITY_STABLE_REF=$stable_ref" \
|
||||
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
- name: Check historical commands, help, and complete CLI compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
run: |
|
||||
make schema-compatibility \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
|
||||
@@ -21,6 +21,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted routing policy
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
@@ -29,12 +34,13 @@ jobs:
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
const {
|
||||
REVIEWER_POOL,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./.github/reviewer-routing.js');
|
||||
const reviewerPool = REVIEWER_POOL;
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
@@ -67,6 +73,21 @@ jobs:
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
let changedFiles;
|
||||
try {
|
||||
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
changedFiles = [];
|
||||
}
|
||||
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
@@ -77,13 +98,9 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
const existingRequestedReviewers = new Set(
|
||||
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
|
||||
);
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
@@ -116,22 +133,18 @@ jobs:
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
const currentHeadReviewers = new Set(
|
||||
[...latestDecisionByLogin.values()]
|
||||
.filter(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
)
|
||||
.map(review => review.user.login.toLowerCase()),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
@@ -178,20 +191,42 @@ jobs:
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
const routing = resolveReviewRouting({
|
||||
files: changedFiles,
|
||||
author,
|
||||
latestPusher,
|
||||
fallbackReviewers: ranked,
|
||||
});
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: routing.reviewers,
|
||||
fallbackReviewers: ranked,
|
||||
eligibleReviewers: eligible,
|
||||
});
|
||||
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
|
||||
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(
|
||||
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
|
||||
);
|
||||
|
||||
const satisfiedReviewers = new Set([
|
||||
...currentHeadReviewers,
|
||||
...[...existingRequestedReviewers].filter((reviewer) =>
|
||||
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
|
||||
),
|
||||
]);
|
||||
const requestResult = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: routing.requiredReviewers,
|
||||
satisfiedReviewers: [...satisfiedReviewers],
|
||||
requestReviewer: async (reviewer) => {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
@@ -202,15 +237,23 @@ jobs:
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
return true;
|
||||
},
|
||||
onFailure: (reviewer, error) => {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (requestResult.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
|
||||
core.warning(
|
||||
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
|
||||
@@ -6,15 +6,93 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
|
||||
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
|
||||
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
|
||||
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
|
||||
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
|
||||
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
|
||||
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
|
||||
so callers can reuse returned task, message, and conversation IDs instead
|
||||
of searching message history by content.
|
||||
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
|
||||
reference with the progressive routing layout, aligns all 20 Sheet topic
|
||||
references across the mono and multi bundles, and adds a content-policy guard
|
||||
that prevents the paired topic trees from drifting again.
|
||||
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
|
||||
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
|
||||
now supports image URLs and local-file uploads through explicit message
|
||||
types, while retaining Markdown as the default and preserving its existing
|
||||
title and text requirements.
|
||||
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
|
||||
commands to list, add, hide, sort, and manage custom conversation shortcuts,
|
||||
with validation and confirmation for destructive removal.
|
||||
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
|
||||
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
|
||||
Base, table, record, attachment, view, dashboard, and workflow operations
|
||||
with explicit confirmation and result-verification semantics for writes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Doc import upload fallback** — `dws doc import` no longer fails on file
|
||||
formats outside the conversion whitelist (html, pdf, zip, extensionless,
|
||||
and any future format): it now hands the file to the document-space upload
|
||||
chain (the same primitive as `dws drive upload --workspace`), stores the
|
||||
original file at the requested `--folder`/`--workspace` target, and prints
|
||||
an explicit stderr notice with the supported-format list and the
|
||||
convert-to-md alternative. The fallback shares the import file checks
|
||||
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
|
||||
a single JSON document, and marks the machine-readable result with
|
||||
`fallback: "upload"` and `converted: false` so agents never mistake the
|
||||
stored file for a converted online document. The fallback fails closed
|
||||
unless the commit response parses as JSON and carries a file identity
|
||||
(exposed as `dentry_id`); empty or unverifiable responses surface as
|
||||
errors instead of fabricated success. Importable formats and
|
||||
`dws sheet import` validation are unchanged.
|
||||
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
- **Release-equivalent PR compatibility gate** (#889) — pull-request
|
||||
admission now runs command-surface compatibility checks against the current
|
||||
release baseline before code reaches `main`.
|
||||
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
|
||||
used for new ready PRs while retaining the existing current-head review and
|
||||
required-check gates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
- **CLI and parameter recovery boundaries** (#864) — command and parameter
|
||||
recovery now fail closed when an Agent-provided path or flag cannot be
|
||||
reconciled with the executable CLI surface, reducing unsafe hallucinated
|
||||
retries.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
@@ -149,6 +227,7 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
+11
-3
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
paths/flags change. CI resolves the exact merge-base, latest reachable
|
||||
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
|
||||
decision seam through
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.4"
|
||||
version "1.0.58-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -24,13 +24,13 @@ help:
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -102,10 +103,22 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
@base_ref="$(BASE_REF)"; \
|
||||
candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$$base_ref" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
@@ -120,7 +133,12 @@ reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-schema-compatibility.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
@@ -17,18 +17,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
root := newRootCommand()
|
||||
snapshot := interfacesnapshot.Capture(root)
|
||||
if err := validateHelpRendering(root, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
approvedMigrationsPath := flags.String(
|
||||
"approved-flag-migrations",
|
||||
"",
|
||||
"merge-base-owned approved flag migration manifest",
|
||||
)
|
||||
candidateMigrationsPath := flags.String(
|
||||
"candidate-flag-migrations",
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
}
|
||||
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
|
||||
current,
|
||||
references,
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
resolved, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || len(remaining) != 0 || resolved == nil {
|
||||
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
|
||||
}
|
||||
if err := renderCommandHelp(resolved); err != nil {
|
||||
return fmt.Errorf("render %q help: %w", command.Path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCommandHelp(command *cobra.Command) (err error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.InitDefaultHelpFlag()
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(&stderr)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("help renderer panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
command.HelpFunc()(command, []string{})
|
||||
if stderr.Len() > 0 {
|
||||
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return fmt.Errorf("help renderer produced empty output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
|
||||
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
return root
|
||||
})
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "injected help failure") {
|
||||
t.Fatalf("run(generate) stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := flagMigrationSnapshot(false)
|
||||
after := flagMigrationSnapshot(true)
|
||||
currentPath := writeSnapshot(t, dir, "current.json", after)
|
||||
basePath := writeSnapshot(t, dir, "base.json", before)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", before)
|
||||
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
|
||||
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
|
||||
exitCode = run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", emptyApproved,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
|
||||
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
|
||||
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr []string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
args: nil,
|
||||
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
args: []string{"unknown"},
|
||||
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
|
||||
}
|
||||
for _, want := range test.wantStderr {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
|
||||
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
|
||||
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
|
||||
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
|
||||
outputDirectory := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
|
||||
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
|
||||
missingTempRoot := filepath.Join(t.TempDir(), "missing")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, missingTempRoot)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate"}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
|
||||
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
args: []string{"compare", "--current", missingPath, "--base", validPath},
|
||||
want: "read current snapshot",
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
args: []string{"compare", "--current", validPath, "--base", missingPath},
|
||||
want: "read main/development baseline snapshot",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"compare", "--current", validPath, "--stable", missingPath},
|
||||
want: "read stable snapshot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
|
||||
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
|
||||
tests := []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "approved manifest",
|
||||
approved: invalidManifest,
|
||||
candidate: validManifest,
|
||||
want: "read approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
approved: validManifest,
|
||||
candidate: invalidManifest,
|
||||
want: "read candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
var stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
}, failingWriter{}, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
|
||||
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
invalidPath := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatalf("write invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readSnapshot(missingPath); err == nil {
|
||||
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readSnapshot(invalidPath); err == nil {
|
||||
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(missingPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
err := validateHelpRendering(root, commandSnapshot("dws missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
|
||||
t.Fatalf("validateHelpRendering resolve error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate(`{{index .Commands 99}}`)
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate("{{")
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template panic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
|
||||
t.Fatalf("validateHelpRendering custom stderr = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("validateHelpRendering empty output = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Short: "root command"}
|
||||
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
|
||||
t.Fatalf("validateHelpRendering normal output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected write failure")
|
||||
}
|
||||
|
||||
var _ io.Writer = failingWriter{}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
|
||||
return path
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
legacy := interfacesnapshot.Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
Default: "",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []interfacesnapshot.Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []interfacesnapshot.Flag{
|
||||
legacy,
|
||||
{Name: "message-id", Type: "string", Default: "", Required: true},
|
||||
}
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed exact migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+39
-8
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
|
||||
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
|
||||
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
|
||||
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
|
||||
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
|
||||
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
|
||||
candidate SHA。
|
||||
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
|
||||
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
|
||||
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
|
||||
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
|
||||
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
|
||||
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
|
||||
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
|
||||
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
|
||||
## 唯一比较入口与信任边界
|
||||
|
||||
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
|
||||
|
||||
- `cmd/interface-snapshot` 生成和比较快照;
|
||||
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
|
||||
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
|
||||
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
|
||||
|
||||
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
|
||||
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
|
||||
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
|
||||
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
|
||||
|
||||
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
|
||||
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
|
||||
`scripts/policy/check-interface-baseline.sh` 只供
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
|
||||
Smoke fixture,不参与迁移审批。
|
||||
|
||||
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
已审批清单固定为:
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "message-id"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": { "present": false },
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
|
||||
- legacy 与 canonical 位于同一个可执行命令;
|
||||
- canonical flag 确实存在;
|
||||
- legacy 与 canonical 类型一致;
|
||||
- legacy 不是指向自身,也不存在 alias chain;
|
||||
- legacy 的 after 状态精确指向该记录中的 canonical flag。
|
||||
|
||||
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
|
||||
|
||||
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
|
||||
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
|
||||
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
|
||||
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
|
||||
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
- 删除 legacy、canonical、命令或其他 flag;
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
|
||||
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
|
||||
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
|
||||
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
|
||||
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
|
||||
|
||||
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
|
||||
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
|
||||
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
fail closed。
|
||||
|
||||
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
|
||||
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
|
||||
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
```sh
|
||||
make interface-integrity \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
|
||||
make schema-compatibility \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
```
|
||||
|
||||
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,16 +70,17 @@
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(5)
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
|
||||
@@ -1,209 +1,926 @@
|
||||
{
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "开启 Base 高级权限总开关的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-put",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-remove",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-bootstrap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get-primary-doc-id",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-schema-snapshot",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取指定图表完整配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取图表分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置图表分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-widgets-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-arrange",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "触发服务端智能布局重排并保留统一写入确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-config-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取仪表盘分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置仪表盘分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+export-data",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+find-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除表单视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-hide",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单字段必填状态与描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取当前视图分享表单配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启或关闭指定视图的分享表单。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单标题和描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-data",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+list-tables",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-bulk-patch",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-history-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "按 recordId 查询变更历史的一对一只读入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取记录主键文档 nodeId 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-links",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-url",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert-by-key",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-base",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-table",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一角色名称、成员与权限配置的创建输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个角色完整权限配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出 Base 全部自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-nodes",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-move-node",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-rename",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "重命名目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-reorder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+url-resolve",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-duplicate",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "复制现有视图配置并生成新视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-frozen-cols",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取 Grid 视图冻结列数的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-lock",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图锁定状态的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-row-height",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图行高像素值的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-lock",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-preset-apply",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-fill-color-rule",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-frozen-cols",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-row-height",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-deploy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "启用指定自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个自动化工作流详情的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "分页列出 Base 自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -1964,7 +2681,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,5 +101,5 @@ skills/mono/
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
|
||||
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := paramAliasResponseForTool(tool)
|
||||
text := c.paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
// print the transport result and need an empty object; smart shortcuts that
|
||||
// inspect a read response receive the smallest shape that lets their full RunE
|
||||
// complete without falling back to a validation error.
|
||||
func paramAliasResponseForTool(tool string) string {
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
for _, key := range []string{"jsonml", "markdown"} {
|
||||
if content, ok := call.args[key].(string); ok {
|
||||
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
@@ -173,6 +186,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
|
||||
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-list dry-run error = %v", err)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
|
||||
t.Fatalf("flag-list dry-run preview = %#v", preview)
|
||||
}
|
||||
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
|
||||
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
|
||||
@@ -48,7 +48,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -212,7 +212,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
|
||||
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{
|
||||
root: root,
|
||||
markdown: "./" + filepath.Base(markdown),
|
||||
json: "./" + filepath.Base(jsonFile),
|
||||
batch: "./" + filepath.Base(batch),
|
||||
binary: "./" + filepath.Base(binary),
|
||||
image: "./" + filepath.Base(image),
|
||||
}
|
||||
}
|
||||
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
|
||||
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.create_with_data",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 294
|
||||
publicShortcutCount = 357
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 295
|
||||
schemaPublishedShortcutCount = 358
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
publiclyDeliveredShortcutCount = 357
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
if got, want := int(product["count"].(float64)), 187; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
|
||||
t.Fatalf("confirmation = %q, want %q", got, want)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
if got, want := len(parameters), 11; got != want {
|
||||
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
|
||||
}
|
||||
if required, _ := parameters["node"]["required"].(bool); !required {
|
||||
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
|
||||
}
|
||||
if required, _ := parameters["command"]["required"].(bool); required {
|
||||
t.Errorf("--command required = true, want runtime custom validation")
|
||||
}
|
||||
wantProperties := map[string]string{
|
||||
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
|
||||
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
|
||||
"expected-revision": "expectedRevision",
|
||||
}
|
||||
for name, want := range wantProperties {
|
||||
if got := schemaContractString(parameters[name]["property"]); got != want {
|
||||
t.Errorf("--%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
|
||||
parameter := parameters[name]
|
||||
if required, _ := parameter["required"].(bool); required {
|
||||
t.Errorf("--%s required = true, want runtime custom validation", name)
|
||||
}
|
||||
if got := schemaContractString(parameter["required_when"]); got != "" {
|
||||
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
|
||||
}
|
||||
}
|
||||
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
|
||||
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
|
||||
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
|
||||
commentParameters := schemaContractMap(comment["parameters"])
|
||||
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
|
||||
if _, ok := commentParameters[name]; !ok {
|
||||
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
|
||||
if got := schemaContractString(commentParameters[name]["property"]); got != want {
|
||||
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
|
||||
exportFormat := schemaContractMap(export["parameters"])["export-format"]
|
||||
if required, _ := exportFormat["required"].(bool); required {
|
||||
t.Fatalf("export --export-format required = true, want compatibility default")
|
||||
}
|
||||
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
|
||||
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
|
||||
}
|
||||
|
||||
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
|
||||
constraints, _ := importLeaf["constraints"].(map[string]any)
|
||||
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
|
||||
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -568,9 +568,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat +flag-list",
|
||||
Aliases: map[string]string{
|
||||
"limit": "size",
|
||||
"limit": "page-size",
|
||||
},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "page-size", "per-page"},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "per-page"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +group-members",
|
||||
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "doc +version-list",
|
||||
Aliases: map[string]string{
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"next-cursor": "cursor",
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"url": "node",
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
CLIPath: "doc +version-revert",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
@@ -148,7 +149,7 @@
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
@@ -361,7 +362,7 @@
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
|
||||
@@ -592,10 +592,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
|
||||
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
|
||||
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Aliases are registered with the main kind and hidden.
|
||||
for _, alias := range []string{"i-alias", "sl-alias"} {
|
||||
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
|
||||
f := cmd.Flags().Lookup(alias)
|
||||
if f == nil || !f.Hidden {
|
||||
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
|
||||
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
|
||||
t.Fatal("int alias must be registered as int")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package runtimeannotate
|
||||
|
||||
// CLI flag alias evidence is kept in this narrow file so the base-owned
|
||||
// Interface Snapshot helper can add the protocol constants to an older stable
|
||||
// worktree without replacing that revision's complete runtimeannotate package.
|
||||
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
|
||||
// Schema synonym or final payload-equivalence proof.
|
||||
const (
|
||||
AnnotationFlagAliasOf = "dws.compat.alias_of"
|
||||
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
|
||||
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
|
||||
)
|
||||
+179
-94
@@ -5,12 +5,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -123,113 +126,195 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// recordQueryFetchAll implements --all auto-pagination for record query.
|
||||
// - pageLimit controls max pages (default 50, 0 = unlimited)
|
||||
// - Mid-loop errors break gracefully, outputting already-fetched data
|
||||
// - Merged result preserves last page's cursor so caller can resume
|
||||
// It prints only a complete result. A page limit, empty/invalid response,
|
||||
// transport failure, or cursor cycle returns a non-zero structured error whose
|
||||
// details retain the incomplete records and retry cursor.
|
||||
func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
|
||||
const pageDelayMs = 200
|
||||
const serverID = "aitable"
|
||||
|
||||
ctx := context.Background()
|
||||
var allRecords []any
|
||||
page := 0
|
||||
lastCursor := ""
|
||||
|
||||
for {
|
||||
page++
|
||||
|
||||
if page > 1 {
|
||||
helperSleep(time.Duration(pageDelayMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page)
|
||||
|
||||
result, err := deps.Caller.CallTool(ctx, serverID, "query_records", toolArgs)
|
||||
if err != nil {
|
||||
if page == 1 {
|
||||
return WrapError(err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[page %d] error, stopping pagination: %v\n", page, err)
|
||||
break
|
||||
}
|
||||
|
||||
// Extract text content from tool result
|
||||
var text string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" && c.Text != "" {
|
||||
text = c.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
break
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
if page == 1 {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Extract records from response
|
||||
var pageRecords []any
|
||||
if data, ok := resp["data"].(map[string]any); ok {
|
||||
if recs, ok := data["records"].([]any); ok {
|
||||
pageRecords = recs
|
||||
}
|
||||
} else if recs, ok := resp["records"].([]any); ok {
|
||||
pageRecords = recs
|
||||
}
|
||||
allRecords = append(allRecords, pageRecords...)
|
||||
|
||||
// Extract cursor for next page (MCP returns "nextCursor" or "cursor")
|
||||
cursor := ""
|
||||
if data, ok := resp["data"].(map[string]any); ok {
|
||||
if c, ok := data["nextCursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
} else if c, ok := data["cursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
}
|
||||
} else if c, ok := resp["nextCursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
} else if c, ok := resp["cursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
}
|
||||
requestArgs := make(map[string]any, len(toolArgs))
|
||||
for key, value := range toolArgs {
|
||||
requestArgs[key] = value
|
||||
}
|
||||
initialCursor, _ := requestArgs["cursor"].(string)
|
||||
|
||||
effectivePageLimit := pageLimit
|
||||
if pageLimit == 0 {
|
||||
effectivePageLimit = paging.UnlimitedPageLimit
|
||||
}
|
||||
result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) {
|
||||
if cursor == "" {
|
||||
lastCursor = ""
|
||||
break
|
||||
delete(requestArgs, "cursor")
|
||||
} else {
|
||||
requestArgs["cursor"] = cursor
|
||||
}
|
||||
|
||||
// Check page limit (0 = unlimited)
|
||||
if pageLimit > 0 && page >= pageLimit {
|
||||
lastCursor = cursor
|
||||
fmt.Fprintf(os.Stderr, "[pagination] reached page limit (%d), stopping. Use --page-limit 0 to fetch all.\n", pageLimit)
|
||||
break
|
||||
toolResult, err := deps.Caller.CallTool(ctx, serverID, "query_records", requestArgs)
|
||||
if err != nil {
|
||||
return paging.Page{}, err
|
||||
}
|
||||
if toolResult == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned a nil result")
|
||||
}
|
||||
for _, content := range toolResult.Content {
|
||||
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
|
||||
return parseRecordQueryPage(content.Text)
|
||||
}
|
||||
}
|
||||
return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content")
|
||||
}, paging.Options{
|
||||
PageLimit: effectivePageLimit,
|
||||
InterPageDelay: paging.DefaultInterPageDelay,
|
||||
InitialCursor: initialCursor,
|
||||
})
|
||||
|
||||
lastCursor = ""
|
||||
toolArgs["cursor"] = cursor
|
||||
if !result.Complete {
|
||||
return recordQueryIncompleteError(result, pageLimit)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[pagination] done: %d pages, %d total records\n", page, len(allRecords))
|
||||
|
||||
// Build merged output — preserve cursor & has_more for resume capability
|
||||
fmt.Fprintf(os.Stderr, "[pagination] complete: %d pages, %d fetched records\n", result.Pages, len(result.Records))
|
||||
mergedData := map[string]any{
|
||||
"records": allRecords,
|
||||
"totalCount": len(allRecords),
|
||||
"records": result.Records,
|
||||
"fetchedCount": len(result.Records),
|
||||
"hasMore": false,
|
||||
"complete": true,
|
||||
"pages": result.Pages,
|
||||
}
|
||||
if lastCursor != "" {
|
||||
mergedData["cursor"] = lastCursor
|
||||
mergedData["hasMore"] = true
|
||||
} else {
|
||||
mergedData["hasMore"] = false
|
||||
if result.TotalCount != nil {
|
||||
mergedData["totalCount"] = *result.TotalCount
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
|
||||
}
|
||||
|
||||
func parseRecordQueryPage(text string) (paging.Page, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(text))
|
||||
decoder.UseNumber()
|
||||
var response map[string]any
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned invalid JSON: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned more than one JSON value")
|
||||
}
|
||||
return paging.Page{}, fmt.Errorf("query_records returned trailing invalid JSON: %w", err)
|
||||
}
|
||||
if response == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned null instead of an object")
|
||||
}
|
||||
|
||||
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
|
||||
payload := response
|
||||
if rawData, exists := response["data"]; exists {
|
||||
data, ok := rawData.(map[string]any)
|
||||
if !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records data must be an object, got %T", rawData)
|
||||
}
|
||||
payload = data
|
||||
}
|
||||
|
||||
rawRecords, exists := payload["records"]
|
||||
if !exists {
|
||||
return paging.Page{}, fmt.Errorf("query_records response is missing records")
|
||||
}
|
||||
records, ok := rawRecords.([]any)
|
||||
if !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records records must be an array, got %T", rawRecords)
|
||||
}
|
||||
for index, record := range records {
|
||||
if _, ok := record.(map[string]any); !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records records[%d] must be an object, got %T", index, record)
|
||||
}
|
||||
}
|
||||
|
||||
nextCursor := firstNonEmptyString(payload, "nextCursor", "cursor")
|
||||
if hasMore, ok := payload["hasMore"].(bool); ok && hasMore && nextCursor == "" {
|
||||
return paging.Page{}, fmt.Errorf("query_records reported hasMore=true without a next cursor")
|
||||
}
|
||||
totalCount, err := parseOptionalNonNegativeInt(payload["totalCount"])
|
||||
if err != nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records totalCount: %w", err)
|
||||
}
|
||||
return paging.Page{Records: records, NextCursor: nextCursor, TotalCount: totalCount}, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOptionalNonNegativeInt(value any) (*int, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var parsed int64
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
integer, err := typed.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("must be an integer, got %q", typed)
|
||||
}
|
||||
parsed = integer
|
||||
case float64:
|
||||
if math.Trunc(typed) != typed {
|
||||
return nil, fmt.Errorf("must be an integer, got %v", typed)
|
||||
}
|
||||
parsed = int64(typed)
|
||||
case int:
|
||||
parsed = int64(typed)
|
||||
case int64:
|
||||
parsed = typed
|
||||
default:
|
||||
return nil, fmt.Errorf("must be a non-negative integer, got %T", value)
|
||||
}
|
||||
if parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return nil, fmt.Errorf("must fit a non-negative int, got %d", parsed)
|
||||
}
|
||||
result := int(parsed)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func recordQueryIncompleteError(result paging.Result, pageLimit int) error {
|
||||
incomplete := map[string]any{
|
||||
"records": result.Records,
|
||||
"fetchedCount": len(result.Records),
|
||||
"pages": result.Pages,
|
||||
"attempts": result.Attempts,
|
||||
"complete": false,
|
||||
"hasMore": result.HasMore,
|
||||
"stopReason": result.StopReason,
|
||||
}
|
||||
if result.LastCursor != "" {
|
||||
incomplete["cursor"] = result.LastCursor
|
||||
}
|
||||
if result.TotalCount != nil {
|
||||
incomplete["totalCount"] = *result.TotalCount
|
||||
}
|
||||
|
||||
hint := "retry the command; the structured error details preserve fetched records and the retry cursor"
|
||||
if result.StopReason == paging.StopPageLimit {
|
||||
hint = "rerun with --page-limit 0 to require a complete result, or resume from details.cursor"
|
||||
}
|
||||
message := fmt.Sprintf("record pagination incomplete after %d successful page(s) and %d fetched record(s)", result.Pages, len(result.Records))
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation("aitable.query_records.all"),
|
||||
apperrors.WithServerKey("aitable"),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithReason("pagination_"+string(result.StopReason)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"page_limit": pageLimit,
|
||||
"incomplete_result": incomplete,
|
||||
}),
|
||||
apperrors.WithCause(result.Err),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── filters 格式校验 ──────────────────────────────────────────────────────
|
||||
@@ -6966,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("page-size")
|
||||
recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录")
|
||||
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限")
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制")
|
||||
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出")
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
|
||||
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("view-id")
|
||||
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
|
||||
|
||||
@@ -292,15 +292,15 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing
|
||||
t.Fatalf("update view top-level: %v", err)
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}}
|
||||
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}}
|
||||
out = installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") {
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) {
|
||||
t.Fatalf("paginated records = %q, %v", out.String(), err)
|
||||
}
|
||||
caller = &aitableTestCaller{responses: []string{"not-json"}}
|
||||
out = installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") {
|
||||
t.Fatalf("raw first page = %q, %v", out.String(), err)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 {
|
||||
t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err)
|
||||
}
|
||||
caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"trailing invalid JSON": `{"records":[]} {`,
|
||||
"data not object": `{"data":[]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseRecordQueryPage(raw); err == nil {
|
||||
t.Fatalf("parseRecordQueryPage(%q) succeeded", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bad number", value: json.Number("1.5"), wantErr: true},
|
||||
{name: "fractional float", value: float64(1.5), wantErr: true},
|
||||
{name: "whole float", value: float64(2), want: 2},
|
||||
{name: "int", value: int(3), want: 3},
|
||||
{name: "int64", value: int64(4), want: 4},
|
||||
{name: "wrong type", value: "5", wantErr: true},
|
||||
{name: "negative", value: int64(-1), wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseOptionalNonNegativeInt(tc.value)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("value %#v succeeded: %#v", tc.value, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got == nil || *got != tc.want {
|
||||
t.Fatalf("value %#v = %#v, %v", tc.value, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
|
||||
total := 9
|
||||
err := recordQueryIncompleteError(paging.Result{
|
||||
Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1,
|
||||
HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit,
|
||||
TotalCount: &total,
|
||||
}, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "pagination incomplete") {
|
||||
t.Fatalf("incomplete result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) {
|
||||
responses := make([]string, paging.DefaultPageLimit+1)
|
||||
for index := range responses {
|
||||
next := ""
|
||||
if index < len(responses)-1 {
|
||||
next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1)
|
||||
}
|
||||
responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
out := installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
|
||||
t.Fatalf("explicit unlimited pagination failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != len(responses) {
|
||||
t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses))
|
||||
}
|
||||
for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("explicit unlimited output missing %s: %s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type recordQueryE2EStep struct {
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
}
|
||||
|
||||
type recordQueryE2ECaller struct {
|
||||
steps []recordQueryE2EStep
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *recordQueryE2ECaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
cloned := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
cloned[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: cloned})
|
||||
index := len(c.calls) - 1
|
||||
if index >= len(c.steps) {
|
||||
return nil, fmt.Errorf("unexpected tool call %d", index+1)
|
||||
}
|
||||
return c.steps[index].result, c.steps[index].err
|
||||
}
|
||||
|
||||
func (*recordQueryE2ECaller) Format() string { return "json" }
|
||||
func (*recordQueryE2ECaller) DryRun() bool { return false }
|
||||
func (*recordQueryE2ECaller) Fields() string { return "" }
|
||||
func (*recordQueryE2ECaller) JQ() string { return "" }
|
||||
|
||||
func runRecordQueryCLI(t *testing.T, caller *recordQueryE2ECaller, extraArgs ...string) (string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = out
|
||||
args := []string{"record", "query", "--base-id", "base-e2e", "--table-id", "table-e2e", "--all"}
|
||||
args = append(args, extraArgs...)
|
||||
os.Args = append([]string{"dws", "aitable"}, args...)
|
||||
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(args)
|
||||
err := command.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func recordQueryTextStep(text string) recordQueryE2EStep {
|
||||
return recordQueryE2EStep{result: textToolResult(text)}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLICompleteE2E(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"data":{"records":[{"id":"r1"},{"id":"r2"}],"nextCursor":"cursor-2","totalCount":3}}`),
|
||||
recordQueryTextStep(`{"data":{"records":[{"id":"r3"}],"nextCursor":"","totalCount":3}}`),
|
||||
}}
|
||||
out, err := runRecordQueryCLI(t, caller, "--page-limit", "0")
|
||||
if err != nil {
|
||||
t.Fatalf("record query CLI returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"complete": true`, `"fetchedCount": 3`, `"totalCount": 3`, `"id": "r3"`} {
|
||||
if !bytes.Contains([]byte(out), []byte(want)) {
|
||||
t.Fatalf("record query CLI output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2", len(caller.calls))
|
||||
}
|
||||
if got := caller.calls[0].args["cursor"]; got != nil {
|
||||
t.Fatalf("first-page cursor = %#v, want absent", got)
|
||||
}
|
||||
if got := caller.calls[1].args["cursor"]; got != "cursor-2" {
|
||||
t.Fatalf("second-page cursor = %#v, want cursor-2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIFailsClosedE2E(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []recordQueryE2EStep
|
||||
args []string
|
||||
}{
|
||||
{name: "nil result", steps: []recordQueryE2EStep{{result: nil}}},
|
||||
{name: "empty content", steps: []recordQueryE2EStep{{result: &edition.ToolResult{}}}},
|
||||
{name: "empty text", steps: []recordQueryE2EStep{recordQueryTextStep("")}},
|
||||
{name: "invalid json", steps: []recordQueryE2EStep{recordQueryTextStep("{")}},
|
||||
{name: "null payload", steps: []recordQueryE2EStep{recordQueryTextStep("null")}},
|
||||
{name: "missing records", steps: []recordQueryE2EStep{recordQueryTextStep(`{"data":{"nextCursor":"c"}}`)}},
|
||||
{name: "records wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":{}}`)}},
|
||||
{name: "records null", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":null}`)}},
|
||||
{name: "record item wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":["bad"]}`)}},
|
||||
{name: "trailing json", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[]} {}`)}},
|
||||
{name: "has more without cursor", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"hasMore":true}`)}},
|
||||
{name: "invalid total", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"totalCount":"many"}`)}},
|
||||
{name: "first transport error", steps: []recordQueryE2EStep{{err: errors.New("offline")}}},
|
||||
{
|
||||
name: "second page error",
|
||||
steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"retry-me"}`),
|
||||
{err: errors.New("upstream reset")},
|
||||
},
|
||||
args: []string{"--page-limit", "0"},
|
||||
},
|
||||
{
|
||||
name: "page limit",
|
||||
steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"resume-me"}`)},
|
||||
args: []string{"--page-limit", "1"},
|
||||
},
|
||||
{
|
||||
name: "cursor cycle",
|
||||
steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[{"id":"r1"}],"nextCursor":"same"}`),
|
||||
recordQueryTextStep(`{"records":[{"id":"r2"}],"nextCursor":"same"}`),
|
||||
},
|
||||
args: []string{"--page-limit", "0"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: test.steps}
|
||||
out, err := runRecordQueryCLI(t, caller, test.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("CLI treated %s as success; output=%s", test.name, out)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("CLI emitted success output for %s: %q", test.name, out)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason == "" || typed.Details["incomplete_result"] == nil {
|
||||
t.Fatalf("error lacks recovery metadata: %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[]}`),
|
||||
}}
|
||||
if out, err := runRecordQueryCLI(t, caller, "--cursor", "resume-from-here", "--page-limit", "0"); err != nil || out == "" {
|
||||
t.Fatalf("resume query = %q, %v", out, err)
|
||||
}
|
||||
want := map[string]any{"baseId": "base-e2e", "tableId": "table-e2e", "cursor": "resume-from-here"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) {
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
out := &bytes.Buffer{}
|
||||
command.SetOut(out)
|
||||
command.SetErr(out)
|
||||
command.SetArgs([]string{"record", "query", "--help"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("record query help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("record query help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+1008
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type LeafContract = corecmd.ContractDecl
|
||||
|
||||
type LeafSpec struct {
|
||||
Safety contract.SafetySpec
|
||||
Contract LeafContract
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
GroupRunE func(*cobra.Command, []string) error
|
||||
CallMCPToolOnServer func(string, string, map[string]any) error
|
||||
DeclareLeafMetadata func(*cobra.Command, LeafSpec) *cobra.Command
|
||||
ValidateRequiredFlag func(*cobra.Command, ...string) error
|
||||
}
|
||||
|
||||
var deps = Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(serverID, toolName string, args map[string]any) error {
|
||||
return fmt.Errorf("im helpers dependency CallMCPToolOnServer is not initialized")
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
}
|
||||
|
||||
func SetDeps(next Deps) {
|
||||
if next.GroupRunE != nil {
|
||||
deps.GroupRunE = next.GroupRunE
|
||||
}
|
||||
if next.CallMCPToolOnServer != nil {
|
||||
deps.CallMCPToolOnServer = next.CallMCPToolOnServer
|
||||
}
|
||||
if next.DeclareLeafMetadata != nil {
|
||||
deps.DeclareLeafMetadata = next.DeclareLeafMetadata
|
||||
}
|
||||
if next.ValidateRequiredFlag != nil {
|
||||
deps.ValidateRequiredFlag = next.ValidateRequiredFlag
|
||||
}
|
||||
}
|
||||
|
||||
func NewChatToolbarCommand() *cobra.Command {
|
||||
return newChatToolbarCommand()
|
||||
}
|
||||
|
||||
func groupRunE(cmd *cobra.Command, args []string) error {
|
||||
return deps.GroupRunE(cmd, args)
|
||||
}
|
||||
|
||||
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
|
||||
return deps.CallMCPToolOnServer(serverID, toolName, args)
|
||||
}
|
||||
|
||||
func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return deps.DeclareLeafMetadata(cmd, spec)
|
||||
}
|
||||
|
||||
func validateRequiredFlags(cmd *cobra.Command, names ...string) error {
|
||||
return deps.ValidateRequiredFlag(cmd, names...)
|
||||
}
|
||||
|
||||
func mustGetFlag(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
if val == "" {
|
||||
val, _ = cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return strings.TrimSpace(val)
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("at least one ID is required")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateRequiredFlagsLocal(cmd *cobra.Command, names ...string) error {
|
||||
for _, name := range names {
|
||||
if strings.TrimSpace(mustGetFlag(cmd, name)) == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("flag --%s is required", name),
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint(fmt.Sprintf("使用 --%s 指定必填参数", name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandBoolFlag(cmd *cobra.Command, name string) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
value, _ := cmd.Flags().GetBool(name)
|
||||
if !value {
|
||||
value, _ = cmd.InheritedFlags().GetBool(name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
toolbarCmd := &cobra.Command{
|
||||
Use: "toolbar",
|
||||
Short: "快捷栏管理",
|
||||
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
toolbarCmd.DisableAutoGenTag = true
|
||||
|
||||
toolbarCmd.AddCommand(
|
||||
newToolbarListCommand(),
|
||||
newToolbarAddCommand(),
|
||||
newToolbarHideCommand(),
|
||||
newToolbarSortCommand(),
|
||||
newToolbarCreateCustomCommand(),
|
||||
newToolbarRemoveCustomCommand(),
|
||||
newToolbarUpdateCustomCommand(),
|
||||
)
|
||||
|
||||
return toolbarCmd
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "将入口添加到快捷栏可见区",
|
||||
Long: "将一个或多个快捷入口从隐藏区移至可见区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "add_shortcut_to_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "add_shortcut_to_bar",
|
||||
CanonicalPath: "chat.add_shortcut_to_bar",
|
||||
CLIPath: "chat toolbar add",
|
||||
PrimaryCLIPath: "chat toolbar add",
|
||||
},
|
||||
Description: "将快捷入口添加到会话快捷栏可见区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_shortcut_to_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口添加到会话快捷栏可见区",
|
||||
UseWhen: []string{"需要将隐藏的快捷入口重新显示在快捷栏中"},
|
||||
AvoidWhen: []string{"创建新的自定义入口应使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarCreateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-custom",
|
||||
Short: "创建自定义快捷栏入口",
|
||||
Long: `创建一个新的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接(可与 --url 相同)
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述(为空时使用 --title)
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
dws chat toolbar create-custom --conversation-id <cid> --title "工具" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com" --extension color=blue --desc "常用工具"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "create_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述(为空时使用 --title)")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "create_custom_shortcut",
|
||||
CanonicalPath: "chat.create_custom_shortcut",
|
||||
CLIPath: "chat toolbar create-custom",
|
||||
PrimaryCLIPath: "chat toolbar create-custom",
|
||||
},
|
||||
Description: "创建自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建自定义快捷栏入口",
|
||||
UseWhen: []string{"需要在快捷栏中新增一个自定义入口(含标题、链接、图标等)"},
|
||||
AvoidWhen: []string{"移动已有入口使用 chat toolbar add / hide;更新已有自定义入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar create-custom --conversation-id <cid> --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func toolbarI18nJSONString(value string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"zh_CN": value})
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func toolbarIconsJSONString(iconMobile string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"iconMobile": iconMobile})
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func hasIntersection(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(a))
|
||||
for _, v := range a {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
for _, v := range b {
|
||||
if _, ok := seen[v]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSystemBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "SYSTEM_BUSY")
|
||||
}
|
||||
|
||||
func parseExtension(cmd *cobra.Command) (map[string]string, error) {
|
||||
raw, _ := cmd.Flags().GetStringArray("extension")
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for _, pair := range raw {
|
||||
idx := strings.Index(pair, "=")
|
||||
if idx <= 0 {
|
||||
return nil, fmt.Errorf("--extension 格式错误,期望 key=value,实际 %q", pair)
|
||||
}
|
||||
key := strings.TrimSpace(pair[:idx])
|
||||
value := strings.TrimSpace(pair[idx+1:])
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("--extension key 不能为空: %q", pair)
|
||||
}
|
||||
if _, exists := result[key]; exists {
|
||||
return nil, fmt.Errorf("--extension 重复 key %q,每个 key 只能指定一次", key)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolbarConversationID(cmd *cobra.Command) (string, error) {
|
||||
cid, _ := cmd.Flags().GetString("conversation-id")
|
||||
cid = strings.TrimSpace(cid)
|
||||
if cid == "" {
|
||||
return "", apperrors.NewValidation(
|
||||
"flag --conversation-id is required",
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint("使用 --conversation-id 指定会话 openConversationId"),
|
||||
apperrors.WithActions("通过 dws chat search 或 dws chat group list 获取会话 ID"),
|
||||
)
|
||||
}
|
||||
return cid, nil
|
||||
}
|
||||
|
||||
func toolbarNewSystemBusyError() error {
|
||||
return apperrors.NewValidation(
|
||||
"服务端繁忙(SYSTEM_BUSY),请稍后重试",
|
||||
apperrors.WithReason("system_busy"),
|
||||
apperrors.WithHint("服务端当前负载较高,建议等待几秒后重试"),
|
||||
apperrors.WithActions("等待后使用相同参数重试"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type toolbarCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type toolbarTestCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
err error
|
||||
calls []toolbarCall
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, toolbarCall{product: productID, tool: toolName, args: args})
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{}"}}}, nil
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) Format() string { return "json" }
|
||||
func (c *toolbarTestCaller) DryRun() bool { return false }
|
||||
func (c *toolbarTestCaller) Fields() string { return "" }
|
||||
func (c *toolbarTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeToolbarCommand(t *testing.T, cmd *cobra.Command, caller *toolbarTestCaller, args ...string) error {
|
||||
t.Helper()
|
||||
initToolbarDepsForTest(t, caller)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func initToolbarDepsForTest(t *testing.T, caller *toolbarTestCaller) {
|
||||
t.Helper()
|
||||
previous := deps
|
||||
SetDeps(Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(productID, toolName string, args map[string]any) error {
|
||||
_, err := caller.CallTool(context.Background(), productID, toolName, args)
|
||||
return err
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
deps = previous
|
||||
})
|
||||
}
|
||||
|
||||
func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "获得用户确认后加 --yes 执行") {
|
||||
t.Fatalf("expected confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireToolbarCall(
|
||||
t *testing.T,
|
||||
caller *toolbarTestCaller,
|
||||
wantProduct, wantTool string,
|
||||
wantArgs map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
if caller.product != wantProduct || caller.tool != wantTool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", caller.product, caller.tool, wantProduct, wantTool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func toolbarCustomArgs(extra ...string) []string {
|
||||
args := []string{"--conversation-id", "cid", "--title", "Title", "--url", "https://example.com/mobile", "--icon-url", "https://example.com/icon.png", "--pc-url", "https://example.com/pc"}
|
||||
return append(args, extra...)
|
||||
}
|
||||
|
||||
func TestHasIntersection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b []int64
|
||||
want bool
|
||||
}{
|
||||
{"both empty", nil, nil, false},
|
||||
{"a empty", nil, []int64{1}, false},
|
||||
{"b empty", []int64{1}, nil, false},
|
||||
{"no overlap", []int64{1, 2, 3}, []int64{4, 5, 6}, false},
|
||||
{"has overlap", []int64{1, 2, 3}, []int64{3, 4, 5}, true},
|
||||
{"single match", []int64{1}, []int64{1}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasIntersection(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSystemBusy(t *testing.T) {
|
||||
if isSystemBusy(nil) {
|
||||
t.Error("isSystemBusy(nil) should be false")
|
||||
}
|
||||
if !isSystemBusy(errors.New("error code: SYSTEM_BUSY")) {
|
||||
t.Error("isSystemBusy should detect SYSTEM_BUSY in error message")
|
||||
}
|
||||
if isSystemBusy(errors.New("some other error")) {
|
||||
t.Error("isSystemBusy should return false for non-SYSTEM_BUSY errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtension(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().StringArray("extension", nil, "")
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("empty extension should not error: %v", err)
|
||||
}
|
||||
if ext != nil {
|
||||
t.Fatalf("empty extension should return nil, got %v", ext)
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("extension", "key1=val1")
|
||||
_ = cmd.Flags().Set("extension", "key2=val2")
|
||||
ext, err = parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid extension should not error: %v", err)
|
||||
}
|
||||
if ext["key1"] != "val1" || ext["key2"] != "val2" {
|
||||
t.Fatalf("unexpected extension map: %v", ext)
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "test2"}
|
||||
cmd2.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd2.Flags().Set("extension", "badformat")
|
||||
_, err = parseExtension(cmd2)
|
||||
if err == nil {
|
||||
t.Fatal("invalid extension format should error")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "test3"}
|
||||
cmd3.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd3.Flags().Set("extension", "color=blue")
|
||||
_ = cmd3.Flags().Set("extension", "color=red")
|
||||
_, err = parseExtension(cmd3)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate extension keys should error")
|
||||
}
|
||||
|
||||
cmd4 := &cobra.Command{Use: "test4"}
|
||||
cmd4.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd4.Flags().Set("extension", " =value")
|
||||
_, err = parseExtension(cmd4)
|
||||
if err == nil {
|
||||
t.Fatal("blank extension key should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarConversationID(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("conversation-id", "", "")
|
||||
|
||||
_, err := toolbarConversationID(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("missing conversation-id should error")
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("conversation-id", "cid123")
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid conversation-id should not error: %v", err)
|
||||
}
|
||||
if cid != "cid123" {
|
||||
t.Fatalf("expected cid123, got %s", cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarDepsDefaultWrappersAndValidationEdges(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "toolbar"}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := groupRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("default groupRunE returned error: %v", err)
|
||||
}
|
||||
if err := callMCPToolOnServer("im", "tool", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "CallMCPToolOnServer is not initialized") {
|
||||
t.Fatalf("default callMCPToolOnServer error = %v", err)
|
||||
}
|
||||
if got := DeclareLeafMetadata(cmd, LeafSpec{}); got != cmd {
|
||||
t.Fatal("default DeclareLeafMetadata should return the original command")
|
||||
}
|
||||
|
||||
toolbar := NewChatToolbarCommand()
|
||||
if toolbar.Name() != "toolbar" || len(toolbar.Commands()) != 7 {
|
||||
t.Fatalf("toolbar command = %q with %d children, want toolbar with 7 children", toolbar.Name(), len(toolbar.Commands()))
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "parent"}
|
||||
parent.PersistentFlags().String("conversation-id", "", "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
parent.AddCommand(child)
|
||||
_ = parent.PersistentFlags().Set("conversation-id", " inherited ")
|
||||
if got := mustGetFlag(child, "conversation-id"); got != "inherited" {
|
||||
t.Fatalf("mustGetFlag inherited value = %q, want inherited", got)
|
||||
}
|
||||
|
||||
if _, err := parseCSVInt64(" , "); err == nil || !strings.Contains(err.Error(), "at least one ID") {
|
||||
t.Fatalf("empty csv error = %v", err)
|
||||
}
|
||||
if _, err := parseCSVInt64("1,bad"); err == nil || !strings.Contains(err.Error(), `invalid integer "bad"`) {
|
||||
t.Fatalf("invalid csv error = %v", err)
|
||||
}
|
||||
required := &cobra.Command{Use: "required"}
|
||||
required.Flags().String("title", "", "")
|
||||
if err := validateRequiredFlagsLocal(required, "title"); err == nil ||
|
||||
!strings.Contains(err.Error(), "flag --title is required") {
|
||||
t.Fatalf("validateRequiredFlagsLocal error = %v", err)
|
||||
}
|
||||
if commandBoolFlag(nil, "yes") {
|
||||
t.Fatal("commandBoolFlag(nil) should be false")
|
||||
}
|
||||
boolParent := &cobra.Command{Use: "parent"}
|
||||
boolParent.PersistentFlags().Bool("yes", false, "")
|
||||
boolChild := &cobra.Command{Use: "child"}
|
||||
boolParent.AddCommand(boolChild)
|
||||
_ = boolParent.PersistentFlags().Set("yes", "true")
|
||||
if !commandBoolFlag(boolChild, "yes") {
|
||||
t.Fatal("commandBoolFlag should read inherited true value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsValidationEdges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "add blank conversation id",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "add blank shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "add invalid shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "hide blank conversation id",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "hide blank shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "hide invalid shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "list blank conversation id",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", " "},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank conversation id",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", " ", "--sorted-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", " "},
|
||||
wantError: "flag --sorted-ids is required",
|
||||
},
|
||||
{
|
||||
name: "sort invalid sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,bad"},
|
||||
wantError: "--sorted-ids",
|
||||
},
|
||||
{
|
||||
name: "sort invalid unsorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1", "--unsorted-ids", "bad"},
|
||||
wantError: "--unsorted-ids",
|
||||
},
|
||||
{
|
||||
name: "create blank conversation id",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "create blank title",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "create invalid extension",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
{
|
||||
name: "remove blank conversation id",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-id", "42", "--yes"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank conversation id",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank title",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "update invalid extension",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsConvertSystemBusy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "remove-custom",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-id", "1", "--yes"},
|
||||
},
|
||||
{
|
||||
name: "create-custom",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs(),
|
||||
},
|
||||
{
|
||||
name: "update-custom",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "99"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{
|
||||
err: errors.New("remote SYSTEM_BUSY"),
|
||||
}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "SYSTEM_BUSY") {
|
||||
t.Fatalf("expected system_busy validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCreateAndUpdateCustomIncludeOptionalFields(t *testing.T) {
|
||||
createCaller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), createCaller, toolbarCustomArgs(
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, createCaller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
|
||||
updateCaller := &toolbarTestCaller{}
|
||||
err = executeToolbarCommand(t, newToolbarUpdateCustomCommand(), updateCaller, toolbarCustomArgs(
|
||||
"--shortcut-id", "99",
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, updateCaller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolbarCreateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs()},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarUpdateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs("--shortcut-id", "99")},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--shortcut-id", "99", "--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarUpdateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarSortValidatesOptionalUnsortedIDs(t *testing.T) {
|
||||
intersectionErr := executeToolbarCommand(t, newToolbarSortCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "2,3",
|
||||
)
|
||||
if intersectionErr == nil || !strings.Contains(intersectionErr.Error(), "不能有交集") {
|
||||
t.Fatalf("expected id_intersection error, got %v", intersectionErr)
|
||||
}
|
||||
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarSortCommand(), caller,
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "3,4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sort returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "sort_shortcut_bar", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"sortedShortcutIds": []int64{1, 2},
|
||||
"unsortedShortcutIds": []int64{3, 4},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarAddHideAndListCallMCPWithExactArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantTool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "101,102"},
|
||||
wantTool: "add_shortcut_to_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{101, 102},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "201,202"},
|
||||
wantTool: "hide_shortcut_from_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{201, 202},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", "cidX"},
|
||||
wantTool: "list_chat_toolbar_shortcuts",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, tt.cmd(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.name, err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", tt.wantTool, tt.wantArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
if caller.product != "" || caller.tool != "" || caller.args != nil {
|
||||
t.Fatalf("expected no MCP call recorded, got product=%q tool=%q args=%#v", caller.product, caller.tool, caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.product != "im" {
|
||||
t.Fatalf("product = %q, want im", call.product)
|
||||
}
|
||||
if call.tool != "remove_custom_shortcut" {
|
||||
t.Fatalf("tool = %q, want remove_custom_shortcut", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX", "shortcutId": int64(42)}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type removeCustomSeamCall struct {
|
||||
openConversationId string
|
||||
shortcutId int64
|
||||
}
|
||||
|
||||
type removeCustomSeamStub struct {
|
||||
calls []removeCustomSeamCall
|
||||
err error
|
||||
}
|
||||
|
||||
// invoke records the seam call and forwards to deps.Caller.CallTool so the
|
||||
// command path still records the same remote invocation shape as production.
|
||||
func (s *removeCustomSeamStub) invoke(openConversationId string, shortcutId int64) error {
|
||||
s.calls = append(s.calls, removeCustomSeamCall{openConversationId: openConversationId, shortcutId: shortcutId})
|
||||
if err := callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamRejectsWithoutYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(stub.calls) != 0 {
|
||||
t.Fatalf("expected 0 seam calls before --yes, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamCallsWithExactArgsWhenYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(stub.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 seam call, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
call := stub.calls[0]
|
||||
if call.openConversationId != "cidX" {
|
||||
t.Fatalf("seam openConversationId = %q, want cidX", call.openConversationId)
|
||||
}
|
||||
if call.shortcutId != int64(42) {
|
||||
t.Fatalf("seam shortcutId = %d, want 42", call.shortcutId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarRemoveCustomDeclaresDestructiveHighRiskConfirmation(t *testing.T) {
|
||||
var got LeafSpec
|
||||
next := deps
|
||||
next.DeclareLeafMetadata = func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
got = spec
|
||||
return cmd
|
||||
}
|
||||
testseam.Swap(t, &deps, next)
|
||||
|
||||
_ = newToolbarRemoveCustomCommand()
|
||||
if got.Safety.Effect != "destructive" || got.Safety.Risk != "high" ||
|
||||
got.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("remove-custom safety = %#v, want destructive/high/user_required", got.Safety)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarHideCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "hide",
|
||||
Short: "将入口从快捷栏可见区隐藏",
|
||||
Long: "将一个或多个快捷入口从可见区移至隐藏区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "hide_shortcut_from_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "hide_shortcut_from_bar",
|
||||
CanonicalPath: "chat.hide_shortcut_from_bar",
|
||||
CLIPath: "chat toolbar hide",
|
||||
PrimaryCLIPath: "chat toolbar hide",
|
||||
},
|
||||
Description: "将快捷入口从会话快捷栏可见区移至隐藏区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_shortcut_from_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口从会话快捷栏可见区隐藏",
|
||||
UseWhen: []string{"需要将快捷栏中的入口隐藏到隐藏区"},
|
||||
AvoidWhen: []string{"永久删除自定义入口应使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询会话快捷栏入口列表",
|
||||
Long: "查询指定会话的快捷栏入口列表,包括可见区和隐藏区的所有入口。",
|
||||
Example: ` dws chat toolbar list --conversation-id <cid>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_chat_toolbar_shortcuts", map[string]any{
|
||||
"openConversationId": cid,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "list_chat_toolbar_shortcuts",
|
||||
CanonicalPath: "chat.list_chat_toolbar_shortcuts",
|
||||
CLIPath: "chat toolbar list",
|
||||
PrimaryCLIPath: "chat toolbar list",
|
||||
},
|
||||
Description: "查询会话快捷栏入口列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_chat_toolbar_shortcuts"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询会话快捷栏入口列表",
|
||||
UseWhen: []string{"需要查看会话快捷栏中有哪些入口、获取入口 ID 时"},
|
||||
AvoidWhen: []string{"修改快捷栏排序使用 chat toolbar sort;创建自定义入口使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar list --conversation-id <cid>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// removeChatToolbarCustomShortcutFn is the package-level injection seam for
|
||||
// the remove-custom command's remote call. The default implementation routes
|
||||
// through callMCPToolOnServer against the im server, so production behavior is
|
||||
// unchanged. Tests swap it with testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub)
|
||||
// to capture the call without hitting a real remote. Like every package-var
|
||||
// seam in this repo it is not safe for t.Parallel tests.
|
||||
var removeChatToolbarCustomShortcutFn = func(openConversationId string, shortcutId int64) error {
|
||||
return callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
})
|
||||
}
|
||||
|
||||
func newToolbarRemoveCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove-custom",
|
||||
Short: "删除自定义快捷栏入口",
|
||||
Long: "删除一个自定义快捷栏入口。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除自定义快捷入口不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标入口及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标自定义入口", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
err = removeChatToolbarCustomShortcutFn(cid, shortcutId)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().Bool("yes", false, "确认执行删除操作")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "remove_custom_shortcut",
|
||||
CanonicalPath: "chat.remove_custom_shortcut",
|
||||
CLIPath: "chat toolbar remove-custom",
|
||||
PrimaryCLIPath: "chat toolbar remove-custom",
|
||||
},
|
||||
Description: "删除自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "删除自定义快捷栏入口",
|
||||
UseWhen: []string{"需要永久删除某个自定义快捷栏入口"},
|
||||
AvoidWhen: []string{"仅隐藏入口使用 chat toolbar hide;更新入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func newToolbarSortCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sort",
|
||||
Short: "排序快捷栏入口",
|
||||
Long: "对会话快捷栏入口进行排序。sorted-ids 指定排序后的可见区入口 ID 列表,unsorted-ids 指定不参与排序放在末尾的入口 ID 列表。两个列表不能有交集。",
|
||||
Example: ` dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
|
||||
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "sorted-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
sortedIds, err := parseCSVInt64(mustGetFlag(cmd, "sorted-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--sorted-ids: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"sortedShortcutIds": sortedIds,
|
||||
}
|
||||
|
||||
if unsortedRaw, _ := cmd.Flags().GetString("unsorted-ids"); unsortedRaw != "" {
|
||||
unsortedIds, err := parseCSVInt64(unsortedRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--unsorted-ids: %w", err)
|
||||
}
|
||||
if hasIntersection(sortedIds, unsortedIds) {
|
||||
return apperrors.NewValidation(
|
||||
"--sorted-ids 与 --unsorted-ids 不能有交集",
|
||||
apperrors.WithReason("id_intersection"),
|
||||
apperrors.WithHint("检查两个 ID 列表,确保同一个 ID 不同时出现在两个列表中"),
|
||||
apperrors.WithActions("修正 ID 列表后重试"),
|
||||
)
|
||||
}
|
||||
toolArgs["unsortedShortcutIds"] = unsortedIds
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "sort_shortcut_bar", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("sorted-ids", "", "排序后的入口 ID 列表(逗号分隔)")
|
||||
cmd.Flags().String("unsorted-ids", "", "不参与排序放在末尾的入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("sorted-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "sort_shortcut_bar",
|
||||
CanonicalPath: "chat.sort_shortcut_bar",
|
||||
CLIPath: "chat toolbar sort",
|
||||
PrimaryCLIPath: "chat toolbar sort",
|
||||
},
|
||||
Description: "对会话快捷栏入口进行排序",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "sort_shortcut_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对会话快捷栏入口进行排序",
|
||||
UseWhen: []string{"需要调整快捷栏入口的显示顺序"},
|
||||
AvoidWhen: []string{"仅添加或隐藏入口使用 chat toolbar add / hide"},
|
||||
Examples: []string{"dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "sorted-ids", Property: "sortedShortcutIds", Required: boolPtr(true)},
|
||||
{Name: "unsorted-ids", Property: "unsortedShortcutIds", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarUpdateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-custom",
|
||||
Short: "更新自定义快捷栏入口",
|
||||
Long: `更新一个已有的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--shortcut-id 自定义入口 ID
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutId": shortcutId,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "update_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "update_custom_shortcut",
|
||||
CanonicalPath: "chat.update_custom_shortcut",
|
||||
CLIPath: "chat toolbar update-custom",
|
||||
PrimaryCLIPath: "chat toolbar update-custom",
|
||||
},
|
||||
Description: "更新自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新自定义快捷栏入口",
|
||||
UseWhen: []string{"需要修改已有自定义快捷栏入口的标题、链接、图标等属性"},
|
||||
AvoidWhen: []string{"创建新入口使用 chat toolbar create-custom;删除入口使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
want := map[string]any{"cursor": int64(42), "size": "30"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
for _, size := range []string{"-1", "0", "31"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
contains []string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "send returns task ID",
|
||||
command: "send",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"query-send-status --open-task-id <openTaskId>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query returns message and conversation IDs",
|
||||
command: "query-send-status",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"openMessageId",
|
||||
"openConversationId",
|
||||
"chat message edit",
|
||||
"chat message recall",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit includes post-send workflow",
|
||||
command: "edit",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
{
|
||||
name: "recall includes post-send workflow",
|
||||
command: "recall",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", test.command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
|
||||
}
|
||||
}
|
||||
if test.notContain != "" && strings.Contains(help, test.notContain) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatCatalogSafetyMetadataIsExplicit(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newChatCommand())
|
||||
|
||||
checked := 0
|
||||
walkChatCatalogLeaves(root, func(cmd *cobra.Command) {
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Identity == nil || final.Safety == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(final.Identity.ProductID) != "chat" {
|
||||
return
|
||||
}
|
||||
checked++
|
||||
canonical := strings.TrimSpace(final.Identity.CanonicalPath)
|
||||
safety := final.Safety
|
||||
if safety.Effect == "" || safety.Risk == "" ||
|
||||
safety.Confirmation == "" || safety.Idempotency == "" {
|
||||
t.Fatalf("chat Catalog leaf %s has incomplete Safety: %+v", canonical, *safety)
|
||||
}
|
||||
if safety.Effect == "read" {
|
||||
if safety.Risk != "low" ||
|
||||
safety.Confirmation != "not_required" ||
|
||||
safety.Idempotency != "idempotent" {
|
||||
t.Fatalf("chat read Catalog leaf %s Safety = %+v, want read/low/not_required/idempotent",
|
||||
canonical, *safety)
|
||||
}
|
||||
}
|
||||
})
|
||||
if checked == 0 {
|
||||
t.Fatal("no chat Catalog leaves checked")
|
||||
}
|
||||
}
|
||||
|
||||
func walkChatCatalogLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Runnable() && !cmd.HasSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkChatCatalogLeaves(child, fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
chathelpers "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/chat"
|
||||
)
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
chathelpers.SetDeps(chathelpers.Deps{
|
||||
GroupRunE: GroupRunE,
|
||||
CallMCPToolOnServer: CallMCPToolOnServer,
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec chathelpers.LeafSpec) *cobra.Command {
|
||||
return DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: spec.Safety,
|
||||
Contract: spec.Contract,
|
||||
})
|
||||
},
|
||||
})
|
||||
return chathelpers.NewChatToolbarCommand()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatToolbarParentCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *chatToolbarParentCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*chatToolbarParentCaller) Format() string { return "json" }
|
||||
func (*chatToolbarParentCaller) DryRun() bool { return false }
|
||||
func (*chatToolbarParentCaller) Fields() string { return "" }
|
||||
func (*chatToolbarParentCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageChatToolbarMountedThroughParentDeps(t *testing.T) {
|
||||
caller := &chatToolbarParentCaller{}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
|
||||
root := newChatCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if command, _, err := root.Find([]string{"toolbar", "list"}); err != nil || command == nil || command.Name() != "list" {
|
||||
t.Fatalf("find chat toolbar list = command %v, err %v", command, err)
|
||||
}
|
||||
|
||||
root.SetArgs([]string{"toolbar", "list", "--conversation-id", "cidX"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("chat toolbar list returned error: %v", err)
|
||||
}
|
||||
if caller.product != "im" || caller.tool != "list_chat_toolbar_shortcuts" {
|
||||
t.Fatalf("tool call = %s/%s, want im/list_chat_toolbar_shortcuts", caller.product, caller.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX"}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
_ = isEmptyDataValidation(value)
|
||||
_ = isEmptyHyperlink(value)
|
||||
}
|
||||
_ = fillIntMatrix(2, 3, 4)
|
||||
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
|
||||
_ = maxColLen2D([][]int{{1}, {2, 3}})
|
||||
_ = checkMatrixShape(1, 2, 1, 2, "x")
|
||||
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{styleSpec{}, 1, 1},
|
||||
}
|
||||
for _, style := range styles {
|
||||
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{"", `[[""]]`, hAlignEnum},
|
||||
{"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
}
|
||||
}
|
||||
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
|
||||
_, _ = findFilterViewByID(views, "one")
|
||||
|
||||
@@ -81,6 +81,53 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat chmod with --yes returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "chat_permission_grant" {
|
||||
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"agentCode": "wukong",
|
||||
"scope": "chat.message:send",
|
||||
"grantType": "timed",
|
||||
"ttl": "24h",
|
||||
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
|
||||
+106
-18
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
|
||||
return callMCPTool("commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
|
||||
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
|
||||
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
|
||||
// 兼容告警,调用方负责结果投影。
|
||||
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
|
||||
step1Args := map[string]any{}
|
||||
if folder != "" {
|
||||
step1Args["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
step1Args["workspaceId"] = workspace
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commitArgs := map[string]any{
|
||||
"uploadKey": uploadKey,
|
||||
"name": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if folder != "" {
|
||||
commitArgs["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
commitArgs["workspaceId"] = workspace
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
|
||||
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||||
var data map[string]any
|
||||
@@ -495,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
|
||||
deps.Out.PrintKeyValue("文档", nodeID)
|
||||
deps.Out.PrintKeyValue("文件", filePath)
|
||||
deps.Out.PrintKeyValue("名称", fileName)
|
||||
deps.Out.PrintKeyValue("类型", mimeType)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"dry_run": true,
|
||||
"preview_kind": "plan",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"executed": false, "nodeId": nodeID, "file": filePath,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||||
},
|
||||
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
@@ -531,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
"Content-Type": mimeType,
|
||||
}
|
||||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_upload_unknown"),
|
||||
apperrors.WithFailureStage("upload_oss"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: insert block into document
|
||||
@@ -588,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_insert_partial"),
|
||||
apperrors.WithFailureStage("insert_block"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "unknown"},
|
||||
},
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
|
||||
} else {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
|
||||
}
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
|
||||
},
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||||
@@ -3764,6 +3849,9 @@ CLI 内部自动完成全部流程:
|
||||
xlsx, xls → 电子表格
|
||||
md, txt → 文字文档
|
||||
xmind, mark → 脑图
|
||||
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
|
||||
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
|
||||
为 md;上传到钉盘请用 dws drive upload
|
||||
|
||||
文件大小限制: 20MB
|
||||
|
||||
@@ -3791,8 +3879,8 @@ CLI 内部自动完成全部流程:
|
||||
},
|
||||
}
|
||||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
|
||||
importCmd.Flags().String("folder-id", "", "")
|
||||
_ = importCmd.Flags().MarkHidden("folder-id")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -17,9 +18,6 @@ const (
|
||||
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
|
||||
initialChunkSize = 10000
|
||||
|
||||
// minChunkSize is the floor; below this we report an error instead of retrying.
|
||||
minChunkSize = 5000
|
||||
|
||||
// longContentWarningThreshold triggers a hint to use --content-file.
|
||||
longContentWarningThreshold = 2048
|
||||
)
|
||||
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
|
||||
// Single write path
|
||||
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
|
||||
chunksWritten = 1
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
|
||||
// Timeout on single write — fallback to chunked with halved size
|
||||
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
|
||||
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) {
|
||||
// The server may have committed the write before the client observed the
|
||||
// timeout. Replaying create/append here can duplicate a document or
|
||||
// content, so fail closed and require inspection before any retry.
|
||||
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
|
||||
}
|
||||
} else {
|
||||
// Chunked write path
|
||||
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0])))
|
||||
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
|
||||
}
|
||||
nodeID = extractNodeIDFromResult(resultText)
|
||||
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
|
||||
}
|
||||
lastResponse = resultText
|
||||
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
newSize := chunkSize / 2
|
||||
if newSize < minChunkSize {
|
||||
return nodeID, writtenCount, resultText, &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
|
||||
writtenCount, chunkSize, minChunkSize),
|
||||
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
|
||||
nodeID, nodeID),
|
||||
Operation: operation,
|
||||
}
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
|
||||
chunkSize = newSize
|
||||
|
||||
var remaining strings.Builder
|
||||
remaining.WriteString(chunk)
|
||||
for j := i + 1; j < len(chunks); j++ {
|
||||
remaining.WriteString(chunks[j])
|
||||
}
|
||||
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
|
||||
chunks = append(chunks[:i], newChunks...)
|
||||
i-- // retry current index
|
||||
continue
|
||||
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
|
||||
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
|
||||
)
|
||||
}
|
||||
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
|
||||
}
|
||||
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
return nodeID, writtenCount, lastResponse, nil
|
||||
}
|
||||
|
||||
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
|
||||
details := map[string]any{
|
||||
"status": "unknown",
|
||||
"nodeId": nodeID,
|
||||
"chunksWritten": written,
|
||||
"chunksTotal": total,
|
||||
"failedStage": stage,
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("doc_write_commit_unknown"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
|
||||
apperrors.WithDetails(details),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
// isTimeoutError checks if an error message indicates a server-side timeout.
|
||||
func isTimeoutError(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
|
||||
t.Fatalf("long literal single write: %v", err)
|
||||
}
|
||||
|
||||
fallback := strings.Repeat("x", minChunkSize+100)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
|
||||
t.Fatalf("single timeout fallback: %v", err)
|
||||
fallback := strings.Repeat("x", 5100)
|
||||
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, timeoutCaller)
|
||||
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
|
||||
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
|
||||
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
|
||||
}
|
||||
|
||||
chunked := strings.Repeat("x", initialChunkSize+100)
|
||||
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||
if err != nil || written < 3 {
|
||||
t.Fatalf("adaptive retry written=%d err=%v", written, err)
|
||||
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
|
||||
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
args map[string]any
|
||||
wantNode string
|
||||
}{
|
||||
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
|
||||
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
|
||||
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
|
||||
}
|
||||
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("unknown commit metadata = %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +210,16 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
}
|
||||
return "", WrapError(err)
|
||||
}
|
||||
if result == nil {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "MCP 工具返回 nil result,无法判断操作结果",
|
||||
Suggestion: "不要把空回复当作成功;请重试并携带 operation/trace 信息排查服务端",
|
||||
Operation: serverID + "/" + toolName,
|
||||
}
|
||||
}
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" && c.Text != "" {
|
||||
if c.Type == "text" && strings.TrimSpace(c.Text) != "" {
|
||||
dumpRawToolResponse(serverID, toolName, c.Text)
|
||||
var errBody map[string]any
|
||||
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
|
||||
@@ -243,6 +251,12 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
return c.Text, nil
|
||||
}
|
||||
}
|
||||
// Some legacy tools intentionally use an empty text response as an
|
||||
// acknowledgement. This low-level parser cannot know the business contract,
|
||||
// so preserve that representation. Data-returning callers must validate the
|
||||
// operation-specific shape (for example records/tables/valid) before they
|
||||
// report success. Orchestrators whose contract requires a business result use
|
||||
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,11 @@ func TestCrossPlatformCoverageMCPReturnTextClassification(t *testing.T) {
|
||||
}
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "ignored"}, {Type: "text"}}}
|
||||
if got, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err != nil || got != "" {
|
||||
t.Fatalf("empty text result = %q, %v", got, err)
|
||||
t.Fatalf("low-level empty text representation = %q, %v", got, err)
|
||||
}
|
||||
caller.result = nil
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
|
||||
t.Fatal("nil result must fail")
|
||||
}
|
||||
caller.result = textToolResult("shortcut result")
|
||||
if got, err := CallMCPToolTextOnServer("server", "tool", nil); err != nil || got != "shortcut result" {
|
||||
|
||||
@@ -58,6 +58,11 @@ type importFlowConfig struct {
|
||||
timeoutAsResult bool
|
||||
nextCommand string
|
||||
poll importPollPolicy
|
||||
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
|
||||
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
|
||||
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
|
||||
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
|
||||
uploadFallback bool
|
||||
}
|
||||
|
||||
type preparedImportFile struct {
|
||||
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
|
||||
workspaceFlags: []string{"workspace", "workspace-id"},
|
||||
nextCommand: "dws doc import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
// 白名单外的格式改走文档空间的文件上传链路
|
||||
// (与 drive upload --workspace 同一条 doc-space 上传原语),
|
||||
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
|
||||
uploadFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if !cfg.supportedFormats[extension] {
|
||||
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
|
||||
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
|
||||
// uploadFallback 配置的白名单外文件继续走完共享校验,由
|
||||
// runImportCommand 分派到上传回退。
|
||||
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
|
||||
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
|
||||
}
|
||||
|
||||
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
|
||||
return callMCPToolReturnText(ctx, toolName, args)
|
||||
}
|
||||
|
||||
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
|
||||
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
|
||||
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
|
||||
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
|
||||
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
|
||||
// 在线文档转换。
|
||||
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
|
||||
label := file.extension
|
||||
if label == "" {
|
||||
label = "无扩展名"
|
||||
}
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
|
||||
label, cfg.supportedFormatsText))
|
||||
|
||||
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
|
||||
uploadName := file.name
|
||||
if filepath.Ext(uploadName) == "" && file.extension != "" {
|
||||
uploadName += "." + file.extension
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"file": file.path,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"size": file.size,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", uploadName)
|
||||
deps.Out.PrintKeyValue("格式", file.extension)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
|
||||
}
|
||||
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
|
||||
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
|
||||
commit, dentryID, err := parseUploadCommitResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"dentry_id": dentryID,
|
||||
"result": commit,
|
||||
})
|
||||
}
|
||||
|
||||
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
|
||||
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
|
||||
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
|
||||
|
||||
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
|
||||
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
|
||||
// 由调用方向用户提示核对入库结果,而不是伪装成功。
|
||||
func parseUploadCommitResult(text string) (map[string]any, string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
|
||||
}
|
||||
payload := parsed
|
||||
if inner, ok := parsed["result"].(map[string]any); ok {
|
||||
payload = inner
|
||||
}
|
||||
for _, key := range uploadCommitIDKeys {
|
||||
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return parsed, v, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
|
||||
}
|
||||
|
||||
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
|
||||
file, err := prepareImportFile(cmd, args, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
|
||||
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
|
||||
return runImportUploadFallback(cmd, cfg, file)
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
errUploadInfo = errors.New("upload info failed")
|
||||
errUploadPut = errors.New("put failed")
|
||||
)
|
||||
|
||||
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
|
||||
t.Helper()
|
||||
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := &cobra.Command{Use: "import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder", "", "")
|
||||
cmd.Flags().String("workspace", "", "")
|
||||
cmd.Flags().String("folder-id", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
if filePath != "" {
|
||||
if err := cmd.Flags().Set("file", filePath); err != nil {
|
||||
t.Fatalf("set import file: %v", err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
|
||||
uploadSteps := []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
|
||||
}
|
||||
|
||||
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout, warnings bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
|
||||
}
|
||||
if got := caller.args["workspaceId"]; got != "ws-1" {
|
||||
t.Fatalf("commit workspaceId = %v, want ws-1", got)
|
||||
}
|
||||
if got := caller.args["name"]; got != "sales.html" {
|
||||
t.Fatalf("commit name = %v, want original file name with extension", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("fallback markers missing: %#v", payload)
|
||||
}
|
||||
if payload["dentry_id"] != "node-1" {
|
||||
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
|
||||
}
|
||||
if payload["requested_operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("requested_operation = %v", payload["requested_operation"])
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "文件上传链路") {
|
||||
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
|
||||
}
|
||||
if strings.Contains(warnings.String(), "deprecated") {
|
||||
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json dry run stays a single json document", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("dry-run fallback payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
|
||||
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
path := writeImportFixture(t, "HTM")
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if got := caller.args["folderId"]; got != "folder-abc" {
|
||||
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var warnings bytes.Buffer
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
noExt := filepath.Join(t.TempDir(), "README")
|
||||
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, noExt)
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "无扩展名") {
|
||||
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
big := filepath.Join(t.TempDir(), "big.html")
|
||||
f, err := os.Create(big)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(importMaxFileSize + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, big)
|
||||
err = runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
empty := filepath.Join(t.TempDir(), "empty.html")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, empty)
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("importable formats keep the conversion path", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("operation = %v, want conversion path", payload["operation"])
|
||||
}
|
||||
if _, ok := payload["fallback"]; ok {
|
||||
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "--file is required") {
|
||||
t.Fatalf("runImportCommand() error = %v, want --file required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
|
||||
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload chain errors propagate", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
putErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
|
||||
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
|
||||
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
commit string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
|
||||
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
|
||||
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: tc.commit},
|
||||
}})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dentry_id"] != "nested-node-9" {
|
||||
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
|
||||
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,39 +143,42 @@ func leafSpecCompositeLits(t *testing.T) []leafSpecLit {
|
||||
}
|
||||
dir := filepath.Dir(file)
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
|
||||
name := info.Name()
|
||||
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
|
||||
}, 0)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers: %v", err)
|
||||
t.Fatalf("read helpers dir: %v", err)
|
||||
}
|
||||
var out []leafSpecLit
|
||||
for _, pkg := range pkgs {
|
||||
for _, f := range pkg.Files {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
parsed, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers file %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no NewLeafCommand(LeafSpec{...}) literals found")
|
||||
|
||||
@@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
命令结构:
|
||||
dws sheet create 创建钉钉表格文档
|
||||
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
|
||||
dws sheet list 获取全部工作表列表
|
||||
dws sheet info 获取指定工作表详情
|
||||
dws sheet new 新建工作表
|
||||
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet chart update 更新浮动图表
|
||||
dws sheet chart delete 删除浮动图表
|
||||
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
|
||||
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
|
||||
dws sheet import 导入 xlsx/xls 为在线电子表格
|
||||
dws sheet template list 获取表格模板列表
|
||||
dws sheet template search 搜索表格模板
|
||||
@@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
|
||||
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
|
||||
createWithDataCmd := newSheetCreateWithDataCmd()
|
||||
exportCsvCmd := newSheetExportCsvCmd()
|
||||
importCmd := newSheetImportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
@@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
for _, cmd := range standaloneCmds {
|
||||
|
||||
@@ -517,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command {
|
||||
}
|
||||
operations := make([]any, 0, len(ranges))
|
||||
for i, rng := range ranges {
|
||||
idx := strings.Index(rng, "!")
|
||||
if idx <= 0 || idx == len(rng)-1 {
|
||||
return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng)
|
||||
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
|
||||
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
|
||||
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetName := strings.TrimSpace(rng[:idx])
|
||||
rangeAddr := strings.TrimSpace(rng[idx+1:])
|
||||
operations = append(operations, map[string]any{
|
||||
"toolName": "clear_range",
|
||||
"input": map[string]any{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--match-case 区分大小写(默认 false)
|
||||
--match-entire-cell 完整单元格匹配(默认 false)
|
||||
--use-regexp 使用正则表达式匹配(默认 false)
|
||||
--match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值)
|
||||
--include-hidden 包含隐藏行/列(默认 false)
|
||||
--range 限定替换范围,A1 表示法(不传时在整个工作表中替换)
|
||||
|
||||
@@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
toolArgs["matchEntireCell"] = matchEntireCell
|
||||
useRegExp, _ := cmd.Flags().GetBool("use-regexp")
|
||||
toolArgs["useRegExp"] = useRegExp
|
||||
matchFormula, _ := cmd.Flags().GetBool("match-formula")
|
||||
toolArgs["matchFormulaText"] = matchFormula
|
||||
includeHidden, _ := cmd.Flags().GetBool("include-hidden")
|
||||
toolArgs["includeHidden"] = includeHidden
|
||||
return callMCPTool("replace_all", toolArgs)
|
||||
@@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "find", Property: "text"},
|
||||
{Name: "match-formula", Property: "matchFormulaText"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "replacement", Property: "replaceText"},
|
||||
{Name: "use-regexp", Property: "useRegExp"},
|
||||
@@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)")
|
||||
replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
|
||||
replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
|
||||
replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)")
|
||||
replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
|
||||
|
||||
appendCmd := &cobra.Command{
|
||||
|
||||
@@ -3,12 +3,21 @@ package helpers
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。
|
||||
func sizeTypeEnumHint(dimension string) string {
|
||||
if dimension == "ROWS" {
|
||||
return "pixel / standard / auto"
|
||||
}
|
||||
return "pixel / standard"
|
||||
}
|
||||
|
||||
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
|
||||
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
|
||||
func newDimensionCmds() []*cobra.Command {
|
||||
@@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command {
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始
|
||||
|
||||
支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。
|
||||
--hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。
|
||||
当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。
|
||||
|
||||
尺寸模式(--size-type,对齐飞书):
|
||||
pixel 默认,按 --pixel-size 指定的像素值设置
|
||||
standard 恢复默认行高/列宽,无需 --pixel-size
|
||||
auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size
|
||||
|
||||
常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`,
|
||||
Example: ` # 隐藏第 3~4 行
|
||||
dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden
|
||||
@@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
hiddenChanged := cmd.Flags().Changed("hidden")
|
||||
pixelSizeChanged := cmd.Flags().Changed("pixel-size")
|
||||
if !hiddenChanged && !pixelSizeChanged {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个")
|
||||
sizeType, _ := cmd.Flags().GetString("size-type")
|
||||
sizeType = strings.ToLower(strings.TrimSpace(sizeType))
|
||||
// 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard
|
||||
switch {
|
||||
case sizeType == "" || sizeType == "pixel" || sizeType == "standard":
|
||||
case sizeType == "auto" && dimension == "ROWS":
|
||||
case sizeType == "auto":
|
||||
return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)")
|
||||
default:
|
||||
return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType)
|
||||
}
|
||||
// standard/auto 由服务端决定尺寸,无需 --pixel-size
|
||||
sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto"
|
||||
if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)")
|
||||
}
|
||||
// standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素,
|
||||
// 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。
|
||||
if sizeTypeDrivesSize && pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType)
|
||||
}
|
||||
// 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。
|
||||
if sizeType == "pixel" && !pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
@@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
toolArgs["pixelSize"] = pixelSize
|
||||
}
|
||||
if sizeType != "" {
|
||||
toolArgs["sizeType"] = sizeType
|
||||
}
|
||||
return callMCPTool("update_dimension", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000")
|
||||
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
|
||||
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
|
||||
updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)")
|
||||
|
||||
groupDimensionCmd := &cobra.Command{
|
||||
Use: "group-dimension",
|
||||
|
||||
@@ -2,6 +2,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是
|
||||
// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作
|
||||
// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG),
|
||||
// 用测试钉住,避免哪天改回宽松解析没人发现。
|
||||
func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"}
|
||||
|
||||
for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} {
|
||||
locator := []string{"--position", "1"}
|
||||
if cmdName == "update-dimension" {
|
||||
locator = []string{"--start-index", "1", "--hidden"}
|
||||
}
|
||||
for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} {
|
||||
args := append(append(append([]string{}, common...), locator...), "--length", bad)
|
||||
err := executeDimensionCoverage(t, cmdName, args...)
|
||||
if err == nil {
|
||||
t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--length") {
|
||||
t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。
|
||||
func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dimension string
|
||||
sizeType string
|
||||
wantErr string
|
||||
}{
|
||||
// standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size
|
||||
{"rows-standard", "ROWS", "standard", ""},
|
||||
{"rows-auto", "ROWS", "auto", ""},
|
||||
{"columns-standard", "COLUMNS", "standard", ""},
|
||||
{"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"},
|
||||
// 非法枚举的提示文案必须按维度给出不同的合法值列表
|
||||
{"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"},
|
||||
{"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType)
|
||||
err := executeDimensionCoverage(t, "update-dimension", args...)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pixel 仍需 --hidden 或 --pixel-size 之一
|
||||
args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel")
|
||||
if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil {
|
||||
t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeTypeEnumHint(t *testing.T) {
|
||||
if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" {
|
||||
t.Fatalf("ROWS hint = %q", got)
|
||||
}
|
||||
if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" {
|
||||
t.Fatalf("COLUMNS hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
|
||||
@@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T)
|
||||
t.Fatalf("multi-select dropdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个
|
||||
// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。
|
||||
func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"standard-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"auto-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"explicit-pixel-without-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}},
|
||||
"--size-type pixel 必须配合 --pixel-size",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
err := runUpdateDimensionForTest(t, caller, flags)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法组合仍然放行
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
}{
|
||||
{"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}},
|
||||
{"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}},
|
||||
{"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}},
|
||||
{"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}},
|
||||
{"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}},
|
||||
} {
|
||||
t.Run("ok/"+tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
if err := runUpdateDimensionForTest(t, caller, flags); err != nil {
|
||||
t.Fatalf("合法组合被拒: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
for _, c := range newDimensionCmds() {
|
||||
if c.Name() != "update-dimension" {
|
||||
continue
|
||||
}
|
||||
for _, kv := range flags {
|
||||
if err := c.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
return c.RunE(c, nil)
|
||||
}
|
||||
t.Fatal("update-dimension not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
|
||||
//
|
||||
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
|
||||
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
|
||||
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
|
||||
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
|
||||
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
|
||||
//
|
||||
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
|
||||
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
|
||||
// ============================================================================
|
||||
|
||||
func newSheetExportCsvCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export-csv",
|
||||
Short: "导出单个工作表为纯 CSV(同步)",
|
||||
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
|
||||
|
||||
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
|
||||
|
||||
参数说明:
|
||||
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
|
||||
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
|
||||
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
|
||||
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
|
||||
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
|
||||
sheet-export.csv);不传则把 CSV 打到 stdout
|
||||
--allow-truncated 允许数据被截断时仍然导出
|
||||
|
||||
截断行为:
|
||||
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
|
||||
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
|
||||
|
||||
落盘保证:
|
||||
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
|
||||
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
|
||||
|
||||
权限要求:
|
||||
当前用户对目标表格具备可查看权限。`,
|
||||
Example: ` # 导出第一个工作表到 stdout(可管道处理)
|
||||
dws sheet export-csv --node NODE_ID
|
||||
|
||||
# 导出指定工作表为本地 CSV 文件
|
||||
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
|
||||
|
||||
# 只导出某个范围,取原始值
|
||||
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
|
||||
RunE: runSheetExportCsv,
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sheet",
|
||||
Name: "export_csv",
|
||||
CanonicalPath: "sheet.export_csv",
|
||||
CLIPath: "sheet export-csv",
|
||||
PrimaryCLIPath: "sheet export-csv",
|
||||
},
|
||||
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
|
||||
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
|
||||
Examples: []string{
|
||||
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
|
||||
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "range", Property: "range"},
|
||||
{Name: "sheet-id", Property: "sheetId"},
|
||||
{Name: "value-render-option", Property: "valueRenderOption"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
|
||||
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
|
||||
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
|
||||
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
|
||||
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
|
||||
var valueRenderOptionEnum = map[string]bool{
|
||||
"formatted_value": true, "raw_value": true, "formula": true,
|
||||
}
|
||||
|
||||
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
|
||||
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
|
||||
nodeID := mustGetFlag(cmd, "node")
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("flag --node is required")
|
||||
}
|
||||
sheetID, _ := cmd.Flags().GetString("sheet-id")
|
||||
rangeAddr, _ := cmd.Flags().GetString("range")
|
||||
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
|
||||
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
|
||||
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
|
||||
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
|
||||
}
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
|
||||
deps.Out.PrintKeyValue("节点", nodeID)
|
||||
if sheetID != "" {
|
||||
deps.Out.PrintKeyValue("工作表", sheetID)
|
||||
}
|
||||
if outputPath != "" {
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"annotateRowNumbers": false,
|
||||
}
|
||||
if sheetID != "" {
|
||||
toolArgs["sheetId"] = sheetID
|
||||
}
|
||||
if rangeAddr != "" {
|
||||
toolArgs["range"] = rangeAddr
|
||||
}
|
||||
if valueRenderOption != "" {
|
||||
toolArgs["valueRenderOption"] = valueRenderOption
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,进度/警告一律不能污染它。
|
||||
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
}
|
||||
|
||||
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
|
||||
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
|
||||
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
|
||||
// 必须显式加 --allow-truncated。
|
||||
if hasMore && !allowTruncated {
|
||||
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
|
||||
"或确认可接受不完整数据后加 --allow-truncated",
|
||||
firstNonEmpty(outputPath, "stdout"))
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
deps.Out.PrintRaw(csvContent)
|
||||
return nil
|
||||
}
|
||||
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
|
||||
outputPath = filepath.Join(outputPath, "sheet-export.csv")
|
||||
}
|
||||
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
|
||||
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
|
||||
// 失败时原文件保持不变。
|
||||
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
|
||||
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
|
||||
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
|
||||
}
|
||||
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", err)
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
|
||||
//
|
||||
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
|
||||
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
|
||||
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
|
||||
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
|
||||
var data map[string]any
|
||||
if e := json.Unmarshal([]byte(text), &data); e != nil {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
|
||||
}
|
||||
if raw, wrapped := data["result"]; wrapped {
|
||||
result, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
|
||||
}
|
||||
data = result
|
||||
}
|
||||
raw, exists := data["csv"]
|
||||
if !exists {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
|
||||
}
|
||||
csvVal, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
|
||||
}
|
||||
csv = csvVal
|
||||
if hm, ok := data["hasMore"].(bool); ok {
|
||||
hasMore = hm
|
||||
}
|
||||
return csv, hasMore, nil
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "sheet"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := newSheetExportCsvCmd()
|
||||
for index := 0; index < len(args); index += 2 {
|
||||
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
|
||||
t.Fatalf("set %s: %v", args[index], err)
|
||||
}
|
||||
}
|
||||
return runSheetExportCsv(cmd, nil)
|
||||
}
|
||||
|
||||
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
|
||||
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
|
||||
// 成功。这里钉住两条命令的 flag 面互不渗透。
|
||||
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
|
||||
xlsxOnly := []string{}
|
||||
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
|
||||
sort.Strings(xlsxOnly)
|
||||
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
|
||||
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
|
||||
}
|
||||
|
||||
csvFlags := map[string]bool{}
|
||||
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
|
||||
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
|
||||
if !csvFlags[name] {
|
||||
t.Errorf("sheet export-csv 缺少 flag --%s", name)
|
||||
}
|
||||
}
|
||||
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
|
||||
if csvFlags["export-format"] {
|
||||
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvRequiresNode(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node is required") {
|
||||
t.Fatalf("err = %v, want required node", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
|
||||
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
|
||||
t.Fatalf("err = %v, want enum rejection", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
|
||||
}
|
||||
|
||||
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
|
||||
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
|
||||
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
|
||||
t.Fatalf("csv dry run: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
// 截断默认失败,所以要显式放行才能走到落盘/输出。
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
|
||||
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"range", "A1:Z1000"},
|
||||
{"sheetId", "SHEET_1"},
|
||||
{"valueRenderOption", "raw_value"},
|
||||
} {
|
||||
if got := caller.args[kv[0]]; got != kv[1] {
|
||||
t.Errorf("%s 未透传: %#v", kv[0], got)
|
||||
}
|
||||
}
|
||||
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
|
||||
if caller.args["annotateRowNumbers"] != false {
|
||||
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
|
||||
}
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
|
||||
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
|
||||
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil {
|
||||
t.Fatal("截断时未放行应报错,而非静默成功")
|
||||
}
|
||||
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误信息缺少 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != "PREEXISTING\n" {
|
||||
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
|
||||
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("allow-truncated 应允许导出: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
|
||||
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
|
||||
t.Fatalf("完整读取不应要求放行: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil || string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
|
||||
if err != nil {
|
||||
t.Fatalf("read exported csv: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
|
||||
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
|
||||
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
|
||||
previousRename := atomicRename
|
||||
t.Cleanup(func() { atomicRename = previousRename })
|
||||
atomicRename = func(string, string) error { return errors.New("no space left on device") }
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
|
||||
}
|
||||
|
||||
// 临时文件不能留在目录里冒充导出产物。
|
||||
entries, readDirErr := os.ReadDir(filepath.Dir(out))
|
||||
if readDirErr != nil {
|
||||
t.Fatalf("read dir: %v", readDirErr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("目录残留临时文件: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
|
||||
t.Fatalf("err = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGetRangeAsCsvResult(t *testing.T) {
|
||||
// 兼容裸响应与 result 包装两种形状
|
||||
for _, body := range []string{
|
||||
`{"csv":"a,b\n","hasMore":true}`,
|
||||
`{"result":{"csv":"a,b\n","hasMore":true}}`,
|
||||
} {
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(body)
|
||||
if err != nil || csv != "a,b\n" || !hasMore {
|
||||
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
// 字段存在但为空串是合法的(真的空区域)
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
|
||||
if err != nil || csv != "" || hasMore {
|
||||
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
|
||||
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
|
||||
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
|
||||
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{"not json", "解析 get_range_as_csv 响应失败"},
|
||||
{`{"message":"something odd"}`, "缺少 csv 字段"},
|
||||
{`{"csv":123}`, "csv 字段不是字符串"},
|
||||
{`{"csv":null}`, "csv 字段不是字符串"},
|
||||
{`{"result":"not-an-object"}`, "result 不是对象"},
|
||||
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
|
||||
} {
|
||||
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
|
||||
!strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
|
||||
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
|
||||
t.Fatalf("err = %v, want missing csv field", err)
|
||||
}
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
|
||||
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
|
||||
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps.Out.w, deps.Out.errW = &stdout, &stderr
|
||||
|
||||
cmd := newSheetExportCsvCmd()
|
||||
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
|
||||
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
|
||||
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
if err := runSheetExportCsv(cmd, nil); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "a,b\nc,d\n" {
|
||||
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
|
||||
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "CSV 已被截断") {
|
||||
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
|
||||
}
|
||||
}
|
||||
+599
-373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
|
||||
//
|
||||
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
|
||||
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
|
||||
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
|
||||
const budgetTestRange = "A1:Z1000"
|
||||
|
||||
func budgetTestRanges(n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
|
||||
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
|
||||
within, err := json.Marshal(budgetTestRanges(7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
|
||||
if err != nil {
|
||||
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 7 {
|
||||
t.Fatalf("operations = %d, want 7", len(ops))
|
||||
}
|
||||
|
||||
// 第 8 个把累计推到 208000,越过 200000 上限。
|
||||
over, err := json.Marshal(budgetTestRanges(8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
|
||||
if err == nil {
|
||||
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
|
||||
}
|
||||
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
|
||||
writeBatch := func(t *testing.T, items []map[string]any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
item := func(i int) map[string]any {
|
||||
return map[string]any{
|
||||
"sheetId": fmt.Sprintf("Sheet%d", i+1),
|
||||
"range": budgetTestRange,
|
||||
"fontWeight": "bold",
|
||||
}
|
||||
}
|
||||
|
||||
// 累计预算:8 条 × 26000 = 208000 > 200000。
|
||||
overBudget := make([]map[string]any, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
overBudget = append(overBudget, item(i))
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
|
||||
if err == nil {
|
||||
t.Fatal("--batch 累计 208000 格必须被拒绝")
|
||||
}
|
||||
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
|
||||
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i < maxBatchStyleRanges+1; i++ {
|
||||
tooMany = append(tooMany, map[string]any{
|
||||
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
|
||||
})
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
|
||||
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
|
||||
t.Fatalf("error = %v, want --batch item cap", err)
|
||||
}
|
||||
|
||||
// 预算内的正常配置仍然照常展开。
|
||||
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
|
||||
if err != nil {
|
||||
t.Fatalf("2 条应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations = %d, want 2", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
|
||||
var total int64
|
||||
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
|
||||
t.Fatalf("first range: %v", err)
|
||||
}
|
||||
if total != 26000 {
|
||||
t.Fatalf("total = %d, want 26000", total)
|
||||
}
|
||||
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
|
||||
var single int64
|
||||
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
|
||||
t.Fatal("1000x1000=1000000 必须超出累计预算")
|
||||
}
|
||||
}
|
||||
|
||||
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
|
||||
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
|
||||
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
|
||||
probe := &cobra.Command{Use: "probe"}
|
||||
bindStyleFlags(probe)
|
||||
|
||||
bound := map[string]bool{}
|
||||
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
|
||||
|
||||
listed := map[string]bool{}
|
||||
for _, name := range styleFlagNames {
|
||||
if listed[name] {
|
||||
t.Fatalf("styleFlagNames 重复登记 %q", name)
|
||||
}
|
||||
listed[name] = true
|
||||
if !bound[name] {
|
||||
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
|
||||
}
|
||||
}
|
||||
for name := range bound {
|
||||
if !listed[name] {
|
||||
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
|
||||
}
|
||||
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
|
||||
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
|
||||
tooMany := make([]string, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i <= maxBatchStyleRanges; i++ {
|
||||
tooMany = append(tooMany, `"Sheet1!A1"`)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ranges string
|
||||
style bool
|
||||
want string
|
||||
}{
|
||||
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
|
||||
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
|
||||
{
|
||||
name: "over-range-cap",
|
||||
ranges: "[" + strings.Join(tooMany, ",") + "]",
|
||||
style: true,
|
||||
want: "--ranges 最多 100 项",
|
||||
},
|
||||
{
|
||||
name: "missing-sheet-prefix",
|
||||
ranges: `["A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-sheet-prefix",
|
||||
ranges: `[" !A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-range-after-prefix",
|
||||
ranges: `["Sheet1! "]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "bad-range-address",
|
||||
ranges: `["Sheet1!zz"]`,
|
||||
style: true,
|
||||
want: "解析失败",
|
||||
},
|
||||
{
|
||||
name: "no-style-flag",
|
||||
ranges: `["Sheet1!A1:B2"]`,
|
||||
style: false,
|
||||
want: "至少需要指定一个样式参数",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if tc.style {
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
|
||||
//
|
||||
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
|
||||
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
|
||||
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
|
||||
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
|
||||
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchDir := t.TempDir()
|
||||
writeBatch := func(t *testing.T, name, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(batchDir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args func(t *testing.T) []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "set-style-ranges-blank-sheet",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-sheet-id",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-range",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "batch-clear-ranges-blank-sheet",
|
||||
cmd: rangeBatchClearCoverageCommand,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := tc.cmd()
|
||||
cmd.SetArgs(tc.args(t))
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
|
||||
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
|
||||
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "padded.json")
|
||||
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
ops, err := buildBatchStyleOpsFromFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations = %d, want 1", len(ops))
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
input, _ := op["input"].(map[string]any)
|
||||
if input["sheetId"] != " Sheet1 " {
|
||||
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
|
||||
}
|
||||
if input["rangeAddress"] != " A1:B2 " {
|
||||
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
|
||||
_, _, _ = parseA1Range(raw)
|
||||
}
|
||||
_ = fillStringMatrix(2, 2, "x")
|
||||
_ = fillIntMatrix(2, 2, 1)
|
||||
for _, spec := range []*styleSpec{
|
||||
{FontSize: 1, FontSizesJSON: `[[1]]`},
|
||||
{FontSize: -1},
|
||||
{FontSize: 12},
|
||||
{FontSizesJSON: `{`},
|
||||
{FontSizesJSON: `[[1,2]]`},
|
||||
{FontSizesJSON: `[[1]]`},
|
||||
for _, tc := range []struct {
|
||||
scalar int
|
||||
jsonStr string
|
||||
}{
|
||||
{1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""},
|
||||
} {
|
||||
_ = applyFontSize(spec, 1, 1, map[string]any{})
|
||||
if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
|
||||
{"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`,
|
||||
`{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} {
|
||||
_, _ = parseBorderStyles(raw)
|
||||
}
|
||||
for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} {
|
||||
_, _, _ = splitSheetPrefixedRange(raw, 0)
|
||||
}
|
||||
_ = maxColLenStr([][]string{{}, {"a", "b"}})
|
||||
_ = maxColLen2D([][]int{{}, {1, 2}})
|
||||
@@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
|
||||
{styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
|
||||
{styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
|
||||
{styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1},
|
||||
{styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1},
|
||||
{styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1},
|
||||
{styleSpec{FontFamily: "Arial"}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{`}, 1, 1},
|
||||
{styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top",
|
||||
FontColor: "#000", FontWeight: "bold"}, 2, 2},
|
||||
} {
|
||||
_ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
|
||||
}
|
||||
results, _ := payload["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("JSON dry-run results = %#v", payload["results"])
|
||||
// batch-set-style 现在组装为一次 batch_update 原子提交,
|
||||
// dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。
|
||||
if payload["tool"] != "batch_update" {
|
||||
t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"])
|
||||
}
|
||||
entry, _ := results[0].(map[string]any)
|
||||
if entry["dryRun"] != true || entry["tool"] != "update_range" {
|
||||
t.Fatalf("JSON dry-run result = %#v", entry)
|
||||
args, _ := payload["arguments"].(map[string]any)
|
||||
ops, _ := args["operations"].([]any)
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"])
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
if op["toolName"] != "set_cell_range" {
|
||||
t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"])
|
||||
}
|
||||
} else {
|
||||
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
|
||||
for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
|
||||
}
|
||||
@@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默
|
||||
// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键
|
||||
// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用
|
||||
// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。
|
||||
func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`},
|
||||
{"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`},
|
||||
{"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`},
|
||||
{"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"},
|
||||
{"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"},
|
||||
{"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"},
|
||||
{"style-empty-string", `{"top":{"style":""}}`, "缺少 style"},
|
||||
{"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"},
|
||||
{"style-null", `{"top":{"style":null}}`, "缺少 style"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseBorderStyles(tc.json)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") {
|
||||
t.Fatalf("err = %v, want the edge name in the message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
wantColor any
|
||||
}{
|
||||
{"style-only", `{"top":{"style":"solid"}}`, nil},
|
||||
{"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"},
|
||||
{"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := parseBorderStyles(tc.json)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err)
|
||||
}
|
||||
edge, _ := out["top"].(map[string]any)
|
||||
if edge["style"] != "solid" {
|
||||
t.Fatalf("style = %#v, want solid", edge["style"])
|
||||
}
|
||||
if edge["color"] != tc.wantColor {
|
||||
t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。
|
||||
func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) {
|
||||
const badBorder = `{"top":{"style":"solid","colour":"#f00"}}`
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]`
|
||||
if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}},
|
||||
{"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}},
|
||||
{"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
var output bytes.Buffer
|
||||
deps.Out.w = &output
|
||||
deps.Out.errW = &output
|
||||
|
||||
cmd := newRangeSetStyleCmd()
|
||||
if tc.name != "single-range" {
|
||||
cmd = newRangeBatchSetStyleCmd()
|
||||
}
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) {
|
||||
t.Fatalf("err = %v, want the unknown border field rejected", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) {
|
||||
if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil {
|
||||
if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil {
|
||||
t.Fatal("invalid font colors JSON returned nil")
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
|
||||
// become required; an existing path may not gain a new required flag,
|
||||
// - new commands and flags are allowed.
|
||||
//
|
||||
// The single exception to the type rule is an individually reviewed migration
|
||||
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
|
||||
// changed. See reviewed.go.
|
||||
//
|
||||
// Comparing the effective local + inherited set is intentional. It catches a
|
||||
// persistent flag whose scope is accidentally narrowed to its declaring
|
||||
// command, while allowing a local flag to move to an ancestor without breaking
|
||||
@@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
|
||||
continue
|
||||
}
|
||||
if oldFlag.Type != newFlag.Type {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
// Resolve the exemption against the canonical path, not acceptedPath.
|
||||
// An aliased command reaches this function once per accepted spelling,
|
||||
// so keying on acceptedPath would let every alias spelling re-report a
|
||||
// reviewed migration.
|
||||
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
|
||||
flagContractOtherwiseChanged(oldFlag, newFlag) {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
@@ -218,6 +229,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
|
||||
After: newFlag.NoOpt,
|
||||
})
|
||||
}
|
||||
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_alias_target_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.AliasOf,
|
||||
After: newFlag.AliasOf,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_hidden",
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
@@ -247,6 +251,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) {
|
||||
baseLegacy := testFlag("legacy-id", "string", false)
|
||||
baseLegacy.AliasOf = "message-id"
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)),
|
||||
)
|
||||
|
||||
for _, target := range []string{"", "other-id"} {
|
||||
t.Run("target_"+target, func(t *testing.T) {
|
||||
currentLegacy := baseLegacy
|
||||
currentLegacy.AliasOf = target
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand(
|
||||
"dws send",
|
||||
currentLegacy,
|
||||
testFlag("message-id", "string", false),
|
||||
testFlag("other-id", "string", false),
|
||||
),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(
|
||||
comparison.Blocking,
|
||||
"flag_alias_target_changed",
|
||||
"dws send",
|
||||
"legacy-id",
|
||||
) {
|
||||
t.Fatalf("alias target regression was not blocked: %#v", comparison)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
@@ -310,6 +348,511 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
authority := testFlagMigrationManifest(FlagMigrationPending)
|
||||
candidate := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
testFlagMigrationSnapshot(true, true),
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
authority,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("exact base-owned migration was rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
for _, comparison := range report.Comparisons {
|
||||
if len(comparison.Blocking) != 0 {
|
||||
t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
testFlagMigrationManifest(FlagMigrationPending),
|
||||
testFlagMigrationManifest(FlagMigrationConsumed),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("composed reviewed migrations were rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
// registerReviewedFixture puts a fixture command in the reviewed table for the
|
||||
// duration of one test, so the behaviour tests exercise the real lookup instead
|
||||
// of depending on whichever production entries happen to exist.
|
||||
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
|
||||
t.Helper()
|
||||
if _, exists := reviewedFlagTypeChanges[change]; exists {
|
||||
t.Fatalf("fixture %+v collides with a production entry", change)
|
||||
}
|
||||
reviewedFlagTypeChanges[change] = struct{}{}
|
||||
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Without an entry the migration is still a blocking change.
|
||||
if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking)
|
||||
}
|
||||
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
// The reverse migration shares command and flag but not direction, so the
|
||||
// string->int entry must not admit it.
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("int->string must not be admitted by a string->int entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Same flag and direction, different command.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another command must not admit this migration")
|
||||
}
|
||||
|
||||
// Same command and direction, different flag.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another flag must not admit this migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A reviewed migration is only accepted when the rest of the flag's contract
|
||||
// held still. Each case bundles one unrelated regression with the reviewed type
|
||||
// change and requires the type failure to reappear, which also anchors
|
||||
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
|
||||
// drifts out of sync fails here instead of silently widening the exemption.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
base Flag
|
||||
next Flag
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "became required",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Required: true},
|
||||
kind: "flag_became_required",
|
||||
},
|
||||
{
|
||||
name: "lost shorthand",
|
||||
base: Flag{Name: "query", Type: "string", Shorthand: "q"},
|
||||
next: Flag{Name: "query", Type: "int"},
|
||||
kind: "flag_shorthand_changed",
|
||||
},
|
||||
{
|
||||
name: "changed no-opt",
|
||||
base: Flag{Name: "query", Type: "string", NoOpt: "all"},
|
||||
next: Flag{Name: "query", Type: "int", NoOpt: "1"},
|
||||
kind: "flag_no_opt_changed",
|
||||
},
|
||||
{
|
||||
name: "became hidden",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Hidden: true},
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
{
|
||||
name: "changed alias target",
|
||||
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
|
||||
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
|
||||
kind: "flag_alias_target_changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base))
|
||||
current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next))
|
||||
comparison := Compare(current, base, "base")
|
||||
if !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
|
||||
}
|
||||
if !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The exemption is resolved against the canonical Command.Path, never the
|
||||
// alias-expanded accepted path. An aliased command enters compareEffectiveFlags
|
||||
// once per accepted spelling, so keying on the accepted path would let every
|
||||
// alias spelling re-report a reviewed migration.
|
||||
func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// The canonical path admits the migration for every accepted spelling.
|
||||
canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}
|
||||
registerReviewedFixture(t, canonical)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking)
|
||||
}
|
||||
delete(reviewedFlagTypeChanges, canonical)
|
||||
|
||||
// An entry spelled with the alias path matches nothing, because the lookup
|
||||
// never sees the accepted spelling.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an alias-spelled entry must not admit the migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A string -> int migration always changes the recorded default ("" to "0").
|
||||
// Defaults are not part of what this gate enforces, and folding them into
|
||||
// flagContractOtherwiseChanged would make every table entry dead on arrival.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewedEntryPattern matches one table entry as gofmt writes it on a single
|
||||
// line. It is used to read the sibling copy of the table out of the policy
|
||||
// helper, which cannot be imported: that directory is copied into a worktree at
|
||||
// a historical revision and built there, so it must not depend on this package.
|
||||
var reviewedEntryPattern = regexp.MustCompile(
|
||||
`\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`)
|
||||
|
||||
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
|
||||
|
||||
// The modern authority and legacy smoke helper keep mirrored tables so local
|
||||
// smoke checks cannot disagree with the authoritative comparison.
|
||||
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
|
||||
source, err := os.ReadFile(interfaceBaselineReviewedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err)
|
||||
}
|
||||
|
||||
sibling := make(map[flagTypeChange]struct{})
|
||||
for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) {
|
||||
sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{}
|
||||
}
|
||||
|
||||
// Without this the whole guard degrades into comparing two empty sets the
|
||||
// moment the pattern stops matching, which is exactly how a duplicated
|
||||
// allowlist goes stale unnoticed.
|
||||
if len(sibling) == 0 {
|
||||
t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath)
|
||||
}
|
||||
|
||||
for change := range reviewedFlagTypeChanges {
|
||||
if _, ok := sibling[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
for change := range sibling {
|
||||
if _, ok := reviewedFlagTypeChanges[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) {
|
||||
empty := testEmptyFlagMigrationManifest()
|
||||
pending := testFlagMigrationManifest(FlagMigrationPending)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
|
||||
partial := testFlagMigrationSnapshot(false, true)
|
||||
partial.Commands[1].LocalFlags[0].Hidden = true
|
||||
partial.Commands[1].LocalFlags[0].Required = false
|
||||
partial.Commands[1].LocalFlags[0].AliasOf = "message-id"
|
||||
|
||||
typeDrift := testFlagMigrationSnapshot(true, true)
|
||||
typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice"
|
||||
noOptDrift := testFlagMigrationSnapshot(true, true)
|
||||
noOptDrift.Commands[1].LocalFlags[0].NoOpt = ""
|
||||
shorthandDrift := testFlagMigrationSnapshot(true, true)
|
||||
shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
}{
|
||||
{
|
||||
name: "candidate-added pending record cannot approve its own surface change",
|
||||
current: after,
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
},
|
||||
{
|
||||
name: "candidate-added consumed record cannot approve its own surface change",
|
||||
current: after,
|
||||
authority: empty,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "partial migration is rejected",
|
||||
current: partial,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "pending record cannot be falsely consumed before the surface changes",
|
||||
current: before,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "completed surface change must consume its pending record",
|
||||
current: after,
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
},
|
||||
{
|
||||
name: "legacy type drift is outside the approval",
|
||||
current: typeDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "legacy no-opt drift is outside the approval",
|
||||
current: noOptDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "legacy shorthand drift is outside the approval",
|
||||
current: shorthandDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
test.current,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
test.authority,
|
||||
test.candidate,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid transition passed: %#v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
before,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
testEmptyFlagMigrationManifest(),
|
||||
testFlagMigrationManifest(FlagMigrationPending),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pending approval creation was rejected before the surface changed: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
pending := testFlagMigrationManifest(FlagMigrationPending)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Snapshot)
|
||||
kind string
|
||||
flag string
|
||||
}{
|
||||
{
|
||||
name: "unrelated flag removal",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2]
|
||||
},
|
||||
kind: "flag_removed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag type change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].Type = "stringSlice"
|
||||
},
|
||||
kind: "flag_type_changed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag no-opt change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].NoOpt = ""
|
||||
},
|
||||
kind: "flag_no_opt_changed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag shorthand change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].Shorthand = ""
|
||||
},
|
||||
kind: "flag_shorthand_changed",
|
||||
flag: "format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := testFlagMigrationSnapshot(true, true)
|
||||
test.mutate(¤t)
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
current,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible {
|
||||
t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report)
|
||||
}
|
||||
for _, comparison := range report.Comparisons {
|
||||
if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) {
|
||||
t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{
|
||||
"merge-base": after,
|
||||
"stable": before,
|
||||
},
|
||||
consumed,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
references := map[string]Snapshot{
|
||||
"merge-base": after,
|
||||
"stable": after,
|
||||
}
|
||||
|
||||
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
|
||||
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
@@ -348,6 +891,92 @@ func testFlag(name, flagType string, required bool) Flag {
|
||||
return Flag{Name: name, Type: flagType, Required: required}
|
||||
}
|
||||
|
||||
func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot {
|
||||
legacy := Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []Flag{
|
||||
legacy,
|
||||
{
|
||||
Name: "message-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
if includeUnrelated {
|
||||
flags = append(flags, Flag{
|
||||
Name: "format",
|
||||
Shorthand: "f",
|
||||
Type: "string",
|
||||
NoOpt: "json",
|
||||
})
|
||||
}
|
||||
return testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws chat send", flags...),
|
||||
)
|
||||
}
|
||||
|
||||
func testFlagMigrationManifest(state string) FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{
|
||||
{
|
||||
Command: "dws chat send",
|
||||
Legacy: FlagMigrationSide{
|
||||
Name: "legacy-id",
|
||||
Before: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Hidden: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
AliasOf: "message-id",
|
||||
},
|
||||
},
|
||||
Canonical: FlagMigrationSide{
|
||||
Name: "message-id",
|
||||
Before: FlagMigrationState{},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "rename the public flag while preserving the executable legacy alias",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyFlagMigrationManifest() FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{},
|
||||
}
|
||||
}
|
||||
|
||||
func hasChangeKind(changes []Change, kind string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind {
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const FlagMigrationManifestVersion = 1
|
||||
|
||||
const (
|
||||
FlagMigrationPending = "pending"
|
||||
FlagMigrationConsumed = "consumed"
|
||||
)
|
||||
|
||||
type FlagMigrationManifest struct {
|
||||
Version int `json:"version"`
|
||||
Migrations []FlagMigration `json:"migrations"`
|
||||
}
|
||||
|
||||
type FlagMigration struct {
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FlagMigrationSide struct {
|
||||
Name string `json:"name"`
|
||||
Before FlagMigrationState `json:"before"`
|
||||
After FlagMigrationState `json:"after"`
|
||||
}
|
||||
|
||||
type FlagMigrationState struct {
|
||||
Present bool `json:"present"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
NoOpt string `json:"no_opt,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
AliasOf string `json:"alias_of,omitempty"`
|
||||
}
|
||||
|
||||
func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err)
|
||||
}
|
||||
var manifest FlagMigrationManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values")
|
||||
}
|
||||
return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err)
|
||||
}
|
||||
if err := validateFlagMigrationJSONSchema(data); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateFlagMigrationJSONSchema(data []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil {
|
||||
return err
|
||||
}
|
||||
if token, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token)
|
||||
}
|
||||
return fmt.Errorf("read trailing flag migration manifest data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
|
||||
}
|
||||
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
fields := migrationJSONFields(schema)
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for decoder.More() {
|
||||
keyToken, keyErr := decoder.Token()
|
||||
if keyErr != nil {
|
||||
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
|
||||
}
|
||||
// encoding/json guarantees object member names are string tokens.
|
||||
key := keyToken.(string)
|
||||
fieldSchema, exists := fields[key]
|
||||
if !exists {
|
||||
for canonical := range fields {
|
||||
if strings.EqualFold(key, canonical) {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
key,
|
||||
path,
|
||||
canonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.Slice:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
for index := 0; decoder.More(); index++ {
|
||||
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.String:
|
||||
if _, ok := token.(string); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
case reflect.Int:
|
||||
if _, ok := token.(json.Number); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
if _, ok := token.(bool); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
|
||||
}
|
||||
}
|
||||
|
||||
func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
|
||||
fields := make(map[string]reflect.Type, schema.NumField())
|
||||
for index := 0; index < schema.NumField(); index++ {
|
||||
field := schema.Field(index)
|
||||
name := field.Tag.Get("json")
|
||||
if comma := strings.IndexByte(name, ','); comma >= 0 {
|
||||
name = name[:comma]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
fields[name] = field.Type
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest value at %s must be %s, got %s",
|
||||
path,
|
||||
migrationJSONKindDescription(want),
|
||||
migrationJSONTokenDescription(token),
|
||||
)
|
||||
}
|
||||
|
||||
func migrationJSONKindDescription(schema reflect.Type) string {
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
return "an object"
|
||||
case reflect.Slice:
|
||||
return "an array"
|
||||
case reflect.String:
|
||||
return "a string"
|
||||
case reflect.Int:
|
||||
return "a number"
|
||||
case reflect.Bool:
|
||||
return "a boolean"
|
||||
default:
|
||||
return "the declared JSON type"
|
||||
}
|
||||
}
|
||||
|
||||
func migrationJSONTokenDescription(token json.Token) string {
|
||||
if token == nil {
|
||||
return "null"
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); ok {
|
||||
switch delimiter {
|
||||
case '{':
|
||||
return "object"
|
||||
case '[':
|
||||
return "array"
|
||||
default:
|
||||
return fmt.Sprintf("delimiter %q", delimiter)
|
||||
}
|
||||
}
|
||||
switch token.(type) {
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (m FlagMigrationManifest) Validate() error {
|
||||
if m.Version != FlagMigrationManifestVersion {
|
||||
return fmt.Errorf(
|
||||
"unsupported flag migration manifest version %d (want %d)",
|
||||
m.Version,
|
||||
FlagMigrationManifestVersion,
|
||||
)
|
||||
}
|
||||
if m.Migrations == nil {
|
||||
return fmt.Errorf("flag migration manifest migrations must be an array")
|
||||
}
|
||||
seen := make(map[string]bool, len(m.Migrations))
|
||||
legacyTargets := make(map[string]string, len(m.Migrations))
|
||||
for index, migration := range m.Migrations {
|
||||
if err := migration.validate(); err != nil {
|
||||
return fmt.Errorf("flag migration %d: %w", index, err)
|
||||
}
|
||||
key := migration.key()
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration %d duplicates %s", index, key)
|
||||
}
|
||||
seen[key] = true
|
||||
legacyKey := migration.Command + "\x00" + migration.Legacy.Name
|
||||
if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name {
|
||||
return fmt.Errorf(
|
||||
"flag migration %d maps %s --%s to both --%s and --%s",
|
||||
index,
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
canonical,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
legacyTargets[legacyKey] = migration.Canonical.Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) validate() error {
|
||||
if !isExactCommandPath(m.Command) {
|
||||
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" {
|
||||
return fmt.Errorf("migration must include a non-empty reason")
|
||||
}
|
||||
if m.Reason != strings.TrimSpace(m.Reason) {
|
||||
return fmt.Errorf("migration reason must be trimmed")
|
||||
}
|
||||
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
if err := m.Legacy.Before.validate("legacy before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Legacy.After.validate("legacy after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Canonical.Before.validate("canonical before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Canonical.After.validate("canonical after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Legacy.Before.Present || !m.Legacy.After.Present {
|
||||
return fmt.Errorf("legacy flag must remain present before and after migration")
|
||||
}
|
||||
if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden {
|
||||
return fmt.Errorf("legacy flag must migrate exactly from visible to hidden")
|
||||
}
|
||||
if !m.Canonical.After.Present {
|
||||
return fmt.Errorf("canonical flag must be present after migration")
|
||||
}
|
||||
if m.Canonical.After.Hidden {
|
||||
return fmt.Errorf("canonical flag must remain visible")
|
||||
}
|
||||
if !m.Canonical.After.Required {
|
||||
return fmt.Errorf("canonical flag must be required after migration")
|
||||
}
|
||||
if m.Canonical.Before.Present && m.Canonical.Before.Required {
|
||||
return fmt.Errorf("canonical flag must be absent or optional before migration")
|
||||
}
|
||||
if m.Legacy.After.AliasOf != m.Canonical.Name {
|
||||
return fmt.Errorf(
|
||||
"legacy flag after state must declare alias_of %q",
|
||||
m.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" {
|
||||
return fmt.Errorf("alias_of is only valid on the legacy after state")
|
||||
}
|
||||
if m.Canonical.Before.Present {
|
||||
if m.Canonical.Before.Type != m.Canonical.After.Type {
|
||||
return fmt.Errorf("canonical flag type must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand {
|
||||
return fmt.Errorf("canonical flag shorthand must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt {
|
||||
return fmt.Errorf("canonical flag no_opt must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.Scope != m.Canonical.After.Scope {
|
||||
return fmt.Errorf("canonical flag scope must remain unchanged")
|
||||
}
|
||||
}
|
||||
if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type {
|
||||
return fmt.Errorf("legacy and canonical flag types must match exactly")
|
||||
}
|
||||
if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand {
|
||||
return fmt.Errorf("legacy flag shorthand must remain unchanged")
|
||||
}
|
||||
if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt {
|
||||
return fmt.Errorf("legacy flag no_opt must remain unchanged")
|
||||
}
|
||||
if m.Legacy.Before.Scope != m.Legacy.After.Scope {
|
||||
return fmt.Errorf("legacy flag scope must remain unchanged")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s FlagMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
|
||||
return fmt.Errorf("%s absent state must not declare flag attributes", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.Type) == "" {
|
||||
return fmt.Errorf("%s present state requires type", label)
|
||||
}
|
||||
if s.Scope != "local" && s.Scope != "inherited" {
|
||||
return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) key() string {
|
||||
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
}
|
||||
|
||||
func isExactCommandPath(path string) bool {
|
||||
return path != "" &&
|
||||
path == strings.TrimSpace(path) &&
|
||||
strings.Join(strings.Fields(path), " ") == path &&
|
||||
(path == "dws" || strings.HasPrefix(path, "dws ")) &&
|
||||
!strings.ContainsAny(path, "*?[]{}")
|
||||
}
|
||||
|
||||
func isExactFlagName(name string) bool {
|
||||
return name != "" &&
|
||||
name == strings.TrimSpace(name) &&
|
||||
!strings.HasPrefix(name, "-") &&
|
||||
!strings.ContainsAny(name, "*?[]{} /\t\r\n")
|
||||
}
|
||||
|
||||
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
|
||||
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
|
||||
// records participate in the lifecycle check, but never authorize their own
|
||||
// interface change.
|
||||
func CompareAllWithFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) (Report, error) {
|
||||
authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
report := CompareAll(current, references)
|
||||
if len(authorizations) == 0 {
|
||||
return report, nil
|
||||
}
|
||||
|
||||
for index := range report.Comparisons {
|
||||
comparison := &report.Comparisons[index]
|
||||
reference := references[comparison.Reference]
|
||||
filtered := comparison.Blocking[:0]
|
||||
for _, change := range comparison.Blocking {
|
||||
if flagMigrationAuthorizesChange(current, reference, change, authorizations) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, change)
|
||||
}
|
||||
comparison.Blocking = filtered
|
||||
comparison.Compatible = len(filtered) == 0
|
||||
}
|
||||
report.Compatible = true
|
||||
for _, comparison := range report.Comparisons {
|
||||
if !comparison.Compatible {
|
||||
report.Compatible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the
|
||||
// base-owned migration lifecycle, and returns only approvals that may authorize
|
||||
// the current exact interface transition. A non-empty lifecycle requires both a
|
||||
// main/merge-base authority reference and a stable reference. Candidate-added
|
||||
// records never appear in the returned authorization set.
|
||||
func AuthorizeFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) ([]FlagMigration, error) {
|
||||
if err := current.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
|
||||
}
|
||||
for label, snapshot := range references {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
|
||||
}
|
||||
}
|
||||
if err := authority.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate approved flag migrations: %w", err)
|
||||
}
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate candidate flag migrations: %w", err)
|
||||
}
|
||||
return evaluateFlagMigrationLifecycle(current, references, authority, candidate)
|
||||
}
|
||||
|
||||
type flagMigrationPhase string
|
||||
|
||||
const (
|
||||
flagMigrationBefore flagMigrationPhase = "before"
|
||||
flagMigrationAfter flagMigrationPhase = "after"
|
||||
flagMigrationPartial flagMigrationPhase = "partial"
|
||||
)
|
||||
|
||||
func evaluateFlagMigrationLifecycle(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) ([]FlagMigration, error) {
|
||||
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if _, ok := references["stable"]; !ok {
|
||||
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
|
||||
}
|
||||
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference")
|
||||
}
|
||||
|
||||
authorityByKey := flagMigrationIndex(authority)
|
||||
candidateByKey := flagMigrationIndex(candidate)
|
||||
authorizations := make([]FlagMigration, 0, len(authority.Migrations))
|
||||
|
||||
for _, approved := range authority.Migrations {
|
||||
basePhase := matchFlagMigrationPhase(mergeBase, approved)
|
||||
wantBasePhase := flagMigrationBefore
|
||||
if approved.State == FlagMigrationConsumed {
|
||||
wantBasePhase = flagMigrationAfter
|
||||
}
|
||||
if basePhase != wantBasePhase {
|
||||
return nil, fmt.Errorf(
|
||||
"approved flag migration %s is %s in %s, want exact %s state for %s",
|
||||
approved.displayKey(),
|
||||
basePhase,
|
||||
label,
|
||||
wantBasePhase,
|
||||
approved.State,
|
||||
)
|
||||
}
|
||||
|
||||
proposed, exists := candidateByKey[approved.key()]
|
||||
if exists && !sameFlagMigrationApproval(approved, proposed) {
|
||||
return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey())
|
||||
}
|
||||
|
||||
currentPhase := matchFlagMigrationPhase(current, approved)
|
||||
switch approved.State {
|
||||
case FlagMigrationPending:
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey())
|
||||
}
|
||||
switch currentPhase {
|
||||
case flagMigrationBefore:
|
||||
if proposed.State != FlagMigrationPending {
|
||||
return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey())
|
||||
}
|
||||
case flagMigrationAfter:
|
||||
if proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
default:
|
||||
return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey())
|
||||
}
|
||||
case FlagMigrationConsumed:
|
||||
if currentPhase != flagMigrationAfter {
|
||||
return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey())
|
||||
}
|
||||
allReferencesAfter := true
|
||||
for _, reference := range references {
|
||||
if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter {
|
||||
allReferencesAfter = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allReferencesAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey())
|
||||
}
|
||||
if proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
}
|
||||
}
|
||||
|
||||
for _, proposed := range candidate.Migrations {
|
||||
if _, exists := authorityByKey[proposed.key()]; exists {
|
||||
continue
|
||||
}
|
||||
if proposed.State != FlagMigrationPending {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey())
|
||||
}
|
||||
if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey())
|
||||
}
|
||||
if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey())
|
||||
}
|
||||
}
|
||||
|
||||
return authorizations, nil
|
||||
}
|
||||
|
||||
func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) {
|
||||
for _, label := range []string{"merge-base", "main"} {
|
||||
if snapshot, ok := references[label]; ok {
|
||||
return snapshot, label, true
|
||||
}
|
||||
}
|
||||
return Snapshot{}, "", false
|
||||
}
|
||||
|
||||
func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration {
|
||||
index := make(map[string]FlagMigration, len(manifest.Migrations))
|
||||
for _, migration := range manifest.Migrations {
|
||||
index[migration.key()] = migration
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func sameFlagMigrationApproval(left, right FlagMigration) bool {
|
||||
left.State = ""
|
||||
right.State = ""
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase {
|
||||
command, exists := commandIndex(snapshot)[migration.Command]
|
||||
if !exists {
|
||||
return flagMigrationPartial
|
||||
}
|
||||
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
|
||||
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
|
||||
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
|
||||
return flagMigrationBefore
|
||||
}
|
||||
if legacy == migration.Legacy.After && canonical == migration.Canonical.After {
|
||||
return flagMigrationAfter
|
||||
}
|
||||
return flagMigrationPartial
|
||||
}
|
||||
|
||||
func flagMigrationStateForCommand(command Command, name string) FlagMigrationState {
|
||||
for _, flag := range command.LocalFlags {
|
||||
if flag.Name == name {
|
||||
return flagMigrationState(flag, "local")
|
||||
}
|
||||
}
|
||||
for _, flag := range command.InheritedFlags {
|
||||
if flag.Name == name {
|
||||
return flagMigrationState(flag, "inherited")
|
||||
}
|
||||
}
|
||||
return FlagMigrationState{}
|
||||
}
|
||||
|
||||
func flagMigrationState(flag Flag, scope string) FlagMigrationState {
|
||||
return FlagMigrationState{
|
||||
Present: true,
|
||||
Type: flag.Type,
|
||||
Required: flag.Required,
|
||||
Hidden: flag.Hidden,
|
||||
Shorthand: flag.Shorthand,
|
||||
NoOpt: flag.NoOpt,
|
||||
Scope: scope,
|
||||
AliasOf: flag.AliasOf,
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationAuthorizesChange(
|
||||
current Snapshot,
|
||||
reference Snapshot,
|
||||
change Change,
|
||||
authorizations []FlagMigration,
|
||||
) bool {
|
||||
canonicalPath := acceptedPathIndex(reference)[change.Path]
|
||||
if canonicalPath == "" {
|
||||
canonicalPath = change.Path
|
||||
}
|
||||
for _, migration := range authorizations {
|
||||
if canonicalPath != migration.Command ||
|
||||
matchFlagMigrationPhase(reference, migration) != flagMigrationBefore ||
|
||||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
|
||||
continue
|
||||
}
|
||||
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
|
||||
return true
|
||||
}
|
||||
if change.Flag != migration.Canonical.Name {
|
||||
continue
|
||||
}
|
||||
if !migration.Canonical.Before.Present &&
|
||||
migration.Canonical.After.Required &&
|
||||
change.Kind == "required_flag_added" {
|
||||
return true
|
||||
}
|
||||
if migration.Canonical.Before.Present &&
|
||||
!migration.Canonical.Before.Required &&
|
||||
migration.Canonical.After.Required &&
|
||||
change.Kind == "flag_became_required" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m FlagMigration) displayKey() string {
|
||||
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "schema validator rejects a trailing value",
|
||||
input: coverageManifestJSON() + ` {}`,
|
||||
wantErr: "trailing JSON value",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports malformed trailing data",
|
||||
input: coverageManifestJSON() + ` ?`,
|
||||
wantErr: "read trailing flag migration manifest data",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an empty document",
|
||||
input: "",
|
||||
wantErr: "read flag migration manifest value at $",
|
||||
},
|
||||
{
|
||||
name: "schema validator rejects an unknown field",
|
||||
input: `{"version":1,"migrations":[],"unexpected":true}`,
|
||||
wantErr: `unknown field "unexpected"`,
|
||||
},
|
||||
{
|
||||
name: "schema validator reports a malformed field token",
|
||||
input: `{"`,
|
||||
wantErr: "flag migration manifest",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an unclosed object",
|
||||
input: `{"version":1`,
|
||||
wantErr: "close flag migration manifest object",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an unclosed array",
|
||||
input: `{"version":1,"migrations":[`,
|
||||
wantErr: "close flag migration manifest array",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateFlagMigrationJSONSchema([]byte(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
schema reflect.Type
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "manifest must be an object",
|
||||
input: `[]`,
|
||||
schema: reflect.TypeOf(FlagMigrationManifest{}),
|
||||
wantErr: "must be an object, got array",
|
||||
},
|
||||
{
|
||||
name: "migrations must be an array",
|
||||
input: `{}`,
|
||||
schema: reflect.TypeOf([]FlagMigration{}),
|
||||
wantErr: "must be an array, got object",
|
||||
},
|
||||
{
|
||||
name: "integer rejects string",
|
||||
input: `"one"`,
|
||||
schema: reflect.TypeOf(int(0)),
|
||||
wantErr: "must be a number, got string",
|
||||
},
|
||||
{
|
||||
name: "string rejects number",
|
||||
input: `1`,
|
||||
schema: reflect.TypeOf(""),
|
||||
wantErr: "must be a string, got number",
|
||||
},
|
||||
{
|
||||
name: "string rejects boolean",
|
||||
input: `false`,
|
||||
schema: reflect.TypeOf(""),
|
||||
wantErr: "must be a string, got boolean",
|
||||
},
|
||||
{
|
||||
name: "unsupported schema fails closed",
|
||||
input: `1`,
|
||||
schema: reflect.TypeOf(float64(0)),
|
||||
wantErr: "unsupported Go schema type float64",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
decoder := json.NewDecoder(strings.NewReader(test.input))
|
||||
decoder.UseNumber()
|
||||
err := validateMigrationJSONValue(decoder, "$", test.schema)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) {
|
||||
type fieldFixture struct {
|
||||
DefaultName string
|
||||
Ignored string `json:"-"`
|
||||
Tagged string `json:"tagged,omitempty"`
|
||||
}
|
||||
fields := migrationJSONFields(reflect.TypeOf(fieldFixture{}))
|
||||
if _, exists := fields["Ignored"]; exists {
|
||||
t.Fatal("migrationJSONFields() retained a json:- field")
|
||||
}
|
||||
if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") {
|
||||
t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields)
|
||||
}
|
||||
|
||||
if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" {
|
||||
t.Fatalf("migrationJSONKindDescription(float64) = %q", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
token json.Token
|
||||
want string
|
||||
}{
|
||||
{name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`},
|
||||
{name: "unsupported token", token: struct{}{}, want: "struct {}"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := migrationJSONTokenDescription(test.token); got != test.want {
|
||||
t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "initial malformed JSON",
|
||||
input: `{`,
|
||||
wantErr: "unexpected EOF",
|
||||
},
|
||||
{
|
||||
name: "multiple JSON values",
|
||||
input: coverageManifestJSON() + ` {}`,
|
||||
wantErr: "trailing multiple JSON values",
|
||||
},
|
||||
{
|
||||
name: "malformed trailing JSON",
|
||||
input: coverageManifestJSON() + ` {`,
|
||||
wantErr: "read trailing flag migration manifest data",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
|
||||
optionalCanonical := func() FlagMigrationManifest {
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
make func() FlagMigrationManifest
|
||||
mutate func(*FlagMigrationManifest)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "canonical flag name is exact",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" },
|
||||
wantErr: "canonical name must be an exact canonical flag",
|
||||
},
|
||||
{
|
||||
name: "legacy and canonical names differ",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name },
|
||||
wantErr: "legacy and canonical flags must differ",
|
||||
},
|
||||
{
|
||||
name: "legacy before state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" },
|
||||
wantErr: "legacy before present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "legacy after state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" },
|
||||
wantErr: "legacy after present state requires type",
|
||||
},
|
||||
{
|
||||
name: "canonical before state validates",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" },
|
||||
wantErr: "canonical before present state requires type",
|
||||
},
|
||||
{
|
||||
name: "canonical after state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" },
|
||||
wantErr: "canonical after present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "legacy before remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} },
|
||||
wantErr: "legacy flag must remain present before and after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy after remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} },
|
||||
wantErr: "legacy flag must remain present before and after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy starts visible",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true },
|
||||
wantErr: "legacy flag must migrate exactly from visible to hidden",
|
||||
},
|
||||
{
|
||||
name: "legacy ends hidden",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false },
|
||||
wantErr: "legacy flag must migrate exactly from visible to hidden",
|
||||
},
|
||||
{
|
||||
name: "canonical after remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
|
||||
wantErr: "canonical flag must be present after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy after declares alias target",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" },
|
||||
wantErr: `legacy flag after state must declare alias_of "message-id"`,
|
||||
},
|
||||
{
|
||||
name: "legacy before cannot declare alias",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical before cannot declare alias",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical after cannot declare alias",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical type remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" },
|
||||
wantErr: "canonical flag type must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical shorthand remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" },
|
||||
wantErr: "canonical flag shorthand must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical no-opt remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" },
|
||||
wantErr: "canonical flag no_opt must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical scope remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" },
|
||||
wantErr: "canonical flag scope must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy before type matches legacy after",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" },
|
||||
wantErr: "legacy and canonical flag types must match exactly",
|
||||
},
|
||||
{
|
||||
name: "legacy after type matches canonical after",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" },
|
||||
wantErr: "legacy and canonical flag types must match exactly",
|
||||
},
|
||||
{
|
||||
name: "legacy shorthand remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" },
|
||||
wantErr: "legacy flag shorthand must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy no-opt remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" },
|
||||
wantErr: "legacy flag no_opt must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy scope remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" },
|
||||
wantErr: "legacy flag scope must remain unchanged",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest := test.make()
|
||||
test.mutate(&manifest)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) {
|
||||
stateMutations := []struct {
|
||||
name string
|
||||
mutate func(*FlagMigrationState)
|
||||
}{
|
||||
{name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }},
|
||||
{name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }},
|
||||
{name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }},
|
||||
{name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }},
|
||||
{name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }},
|
||||
{name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }},
|
||||
{name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }},
|
||||
}
|
||||
for _, test := range stateMutations {
|
||||
t.Run("absent state rejects "+test.name, func(t *testing.T) {
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
test.mutate(&manifest.Migrations[0].Canonical.Before)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") {
|
||||
t.Fatalf("Validate() error = %v, want absent-state rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
second := coverageManifest(FlagMigrationPending).Migrations[0]
|
||||
second.Canonical.Name = "canonical-two"
|
||||
second.Legacy.After.AliasOf = second.Canonical.Name
|
||||
manifest.Migrations = append(manifest.Migrations, second)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") {
|
||||
t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) {
|
||||
before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false)
|
||||
empty := coverageEmptyManifest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "current snapshot",
|
||||
current: Snapshot{},
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: empty,
|
||||
candidate: empty,
|
||||
wantErr: "validate current interface snapshot",
|
||||
},
|
||||
{
|
||||
name: "reference snapshot",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": {}},
|
||||
authority: empty,
|
||||
candidate: empty,
|
||||
wantErr: "validate merge-base interface snapshot",
|
||||
},
|
||||
{
|
||||
name: "authority manifest",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: FlagMigrationManifest{},
|
||||
candidate: empty,
|
||||
wantErr: "validate approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: empty,
|
||||
candidate: FlagMigrationManifest{},
|
||||
wantErr: "validate candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
partial := coverageMigrationSnapshot(migration, false, false)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
|
||||
|
||||
modified := coverageManifest(FlagMigrationPending)
|
||||
modified.Migrations[0].Reason = "A different reviewed reason."
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "requires merge-base or main",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "requires a main or merge-base reference",
|
||||
},
|
||||
{
|
||||
name: "pending authority requires before base",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "want exact before state for pending",
|
||||
},
|
||||
{
|
||||
name: "consumed authority requires after base",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "want exact after state for consumed",
|
||||
},
|
||||
{
|
||||
name: "missing base command is partial",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot(), "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "is partial in merge-base",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot modify base receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: modified,
|
||||
wantErr: "candidate modified base-owned flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot remove pending receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: empty,
|
||||
wantErr: "candidate removed pending flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot falsely consume pending receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate falsely consumed unchanged flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate must consume completed receipt",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "without marking it consumed",
|
||||
},
|
||||
{
|
||||
name: "candidate rejects partial application",
|
||||
current: partial,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate partially applied flag migration",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt rejects current revert",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate drifted from consumed flag migration",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt cannot be removed early",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: empty,
|
||||
wantErr: "before every reference reached the after state",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt cannot revert to pending",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: pending,
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt becomes stale",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "is stale after all references reached the after state",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt starts pending",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: empty,
|
||||
candidate: consumed,
|
||||
wantErr: "must start pending",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt matches base before",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
wantErr: "does not match the merge-base before state",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt cannot self-authorize",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
wantErr: "cannot authorize its own interface change",
|
||||
},
|
||||
{
|
||||
name: "missing current command is partial",
|
||||
current: coverageRootOnlySnapshot(),
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "candidate partially applied flag migration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"main": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "main": after, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
consumed,
|
||||
empty,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
before,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after},
|
||||
consumed,
|
||||
empty,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
|
||||
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) {
|
||||
t.Run("inherited exact state", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Legacy.Before.Scope = "inherited"
|
||||
pending.Migrations[0].Legacy.After.Scope = "inherited"
|
||||
pending.Migrations[0].Canonical.After.Scope = "inherited"
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing optional canonical becomes required", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, true)
|
||||
after := coverageMigrationSnapshot(migration, true, true)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) {
|
||||
t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
beforeWithOther := before
|
||||
afterWithOther := after
|
||||
beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil))
|
||||
afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil))
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithOther,
|
||||
map[string]Snapshot{"merge-base": beforeWithOther, "stable": beforeWithOther},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated breakage returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") {
|
||||
t.Fatalf("unrelated breakage was hidden: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
partialReference := coverageMigrationSnapshot(migration, false, false)
|
||||
partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": partialReference},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("partial non-authority reference returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible {
|
||||
t.Fatalf("partial reference was incorrectly authorized: %#v", report)
|
||||
}
|
||||
|
||||
beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false)
|
||||
afterWithoutRoot := coverageMigrationSnapshot(migration, true, false)
|
||||
beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:]
|
||||
afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:]
|
||||
afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"}
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithoutRoot,
|
||||
map[string]Snapshot{"merge-base": beforeWithoutRoot, "stable": beforeWithoutRoot},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("rule drift returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") {
|
||||
t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func coverageManifestJSON() string {
|
||||
return `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reviewed exact flag migration."
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
func coverageManifest(state string) FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{
|
||||
{
|
||||
Command: "dws chat send",
|
||||
Legacy: FlagMigrationSide{
|
||||
Name: "legacy-id",
|
||||
Before: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Hidden: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
AliasOf: "message-id",
|
||||
},
|
||||
},
|
||||
Canonical: FlagMigrationSide{
|
||||
Name: "message-id",
|
||||
Before: FlagMigrationState{},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Reviewed exact flag migration.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func coverageEmptyManifest() FlagMigrationManifest {
|
||||
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
}
|
||||
|
||||
func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot {
|
||||
legacyState := migration.Legacy.Before
|
||||
canonicalState := migration.Canonical.Before
|
||||
if after {
|
||||
legacyState = migration.Legacy.After
|
||||
canonicalState = migration.Canonical.After
|
||||
}
|
||||
local := []Flag{}
|
||||
inherited := []Flag{}
|
||||
add := func(name string, state FlagMigrationState) {
|
||||
if !state.Present {
|
||||
return
|
||||
}
|
||||
flag := Flag{
|
||||
Name: name,
|
||||
Shorthand: state.Shorthand,
|
||||
Type: state.Type,
|
||||
NoOpt: state.NoOpt,
|
||||
Required: state.Required,
|
||||
Hidden: state.Hidden,
|
||||
AliasOf: state.AliasOf,
|
||||
}
|
||||
if state.Scope == "inherited" {
|
||||
inherited = append(inherited, flag)
|
||||
return
|
||||
}
|
||||
local = append(local, flag)
|
||||
}
|
||||
add(migration.Legacy.Name, legacyState)
|
||||
add(migration.Canonical.Name, canonicalState)
|
||||
aliases := []string{}
|
||||
if commandAlias {
|
||||
aliases = []string{"deliver"}
|
||||
}
|
||||
return coverageSnapshot(
|
||||
coverageCommand("dws", nil, nil, nil),
|
||||
coverageCommand("dws chat", nil, nil, nil),
|
||||
coverageCommand(migration.Command, aliases, local, inherited),
|
||||
)
|
||||
}
|
||||
|
||||
func coverageRootOnlySnapshot() Snapshot {
|
||||
return coverageSnapshot(coverageCommand("dws", nil, nil, nil))
|
||||
}
|
||||
|
||||
func coverageSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func coverageCommand(path string, aliases []string, local, inherited []Flag) Command {
|
||||
if aliases == nil {
|
||||
aliases = []string{}
|
||||
}
|
||||
if local == nil {
|
||||
local = []Flag{}
|
||||
}
|
||||
if inherited == nil {
|
||||
inherited = []Flag{}
|
||||
}
|
||||
return Command{
|
||||
Path: path,
|
||||
Runnable: true,
|
||||
Aliases: aliases,
|
||||
LocalFlags: local,
|
||||
InheritedFlags: inherited,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type flagMigrationErrorReader struct{}
|
||||
|
||||
func (flagMigrationErrorReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("injected read failure")
|
||||
}
|
||||
|
||||
const validFlagMigrationManifestJSON = `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reviewed message identifier flag migration."
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
|
||||
"version": 1,
|
||||
"migrations": [],
|
||||
"allow_all_chat_flags": true
|
||||
}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`{"version": 999, "version": 1, "migrations": []}`,
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1),
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`{"Version": 1, "migrations": []}`,
|
||||
`{"version": 1, "Version": 1, "migrations": []}`,
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1),
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "non-canonical field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1),
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1),
|
||||
`{"version": null, "migrations": []}`,
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1),
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1),
|
||||
`{"version": "1", "migrations": []}`,
|
||||
`{"version": 1, "migrations": {}}`,
|
||||
} {
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil {
|
||||
t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(flagMigrationErrorReader{})
|
||||
if err == nil || !strings.Contains(err.Error(), "injected read failure") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) {
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unsupported version",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1),
|
||||
wantErr: "unsupported flag migration manifest version",
|
||||
},
|
||||
{
|
||||
name: "missing migrations array",
|
||||
input: `{"version": 1}`,
|
||||
wantErr: "migrations must be an array",
|
||||
},
|
||||
{
|
||||
name: "null migrations array",
|
||||
input: `{"version": 1, "migrations": null}`,
|
||||
wantErr: "migrations must be an array",
|
||||
},
|
||||
{
|
||||
name: "wildcard command",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1),
|
||||
wantErr: "exact command path",
|
||||
},
|
||||
{
|
||||
name: "wildcard flag",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1),
|
||||
wantErr: "exact legacy flag",
|
||||
},
|
||||
{
|
||||
name: "empty reason",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1),
|
||||
wantErr: "non-empty reason",
|
||||
},
|
||||
{
|
||||
name: "invalid state",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1),
|
||||
wantErr: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "legacy removed",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1),
|
||||
wantErr: "legacy flag must remain present",
|
||||
},
|
||||
{
|
||||
name: "canonical hidden",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1),
|
||||
wantErr: "canonical flag must remain visible",
|
||||
},
|
||||
{
|
||||
name: "canonical remains optional",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be required after migration",
|
||||
},
|
||||
{
|
||||
name: "canonical was already required",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"before": {"present": false}`,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be absent or optional before migration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
|
||||
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duplicate := manifest
|
||||
duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0])
|
||||
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
|
||||
t.Fatalf("duplicate migration error = %v", err)
|
||||
}
|
||||
|
||||
whitespaceReason := manifest
|
||||
whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
whitespaceReason.Migrations[0].Reason = " reviewed "
|
||||
if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") {
|
||||
t.Fatalf("whitespace reason error = %v", err)
|
||||
}
|
||||
|
||||
canonicalDrift := manifest
|
||||
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
|
||||
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
|
||||
t.Fatalf("canonical type drift error = %v", err)
|
||||
}
|
||||
|
||||
_, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "trailing") {
|
||||
t.Fatalf("trailing JSON error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
got, err := AuthorizeFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AuthorizeFlagMigrations() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, pending.Migrations) {
|
||||
t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
// flagTypeChange identifies one flag type migration exactly: which command,
|
||||
// which flag, and in which direction. Nothing is matched by wildcard — an entry
|
||||
// that differs in any of the four fields does not apply.
|
||||
//
|
||||
// CommandPath is the canonical Command.Path form, which always includes the root
|
||||
// command name ("dws minutes permission apply"). It is deliberately *not* the
|
||||
// alias-expanded accepted path: a command reachable through an alias enters
|
||||
// compareEffectiveFlags once per accepted spelling, and keying on the accepted
|
||||
// path would let every alias spelling bypass the exemption and re-report the
|
||||
// change.
|
||||
//
|
||||
// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and
|
||||
// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline
|
||||
// fails if they drift. The duplication is forced rather than chosen:
|
||||
// check-authoritative-interface-baselines.sh copies the whole
|
||||
// scripts/policy/interface-baseline directory into a worktree checked out at a
|
||||
// historical revision and builds it there, so that copy cannot import a package
|
||||
// this branch adds.
|
||||
type flagTypeChange struct {
|
||||
CommandPath string
|
||||
Flag string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
|
||||
// migrations this gate accepts. A flag type is part of the published CLI
|
||||
// contract, and a type swap cannot be proven safe from the type names alone —
|
||||
// whether old invocations still parse depends on the concrete value domain the
|
||||
// command enforces. So a migration is accepted only when this exact command,
|
||||
// flag, and old→new pair appear here. Adding an entry is a contract decision
|
||||
// and belongs in review, not in a feature change.
|
||||
//
|
||||
// Entries are direction-sensitive by construction: "string" → "int" is a
|
||||
// separate key from "int" → "string" and only the reviewed direction is
|
||||
// accepted. The reverse is not a candidate for this table — widening an int
|
||||
// flag back to string lets values the parser used to reject reach RunE, which
|
||||
// is a genuine loosening of validation rather than a type migration.
|
||||
//
|
||||
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
|
||||
// only when nothing else about the flag moved. See compareEffectiveFlags.
|
||||
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
|
||||
// "dws minutes permission apply --policy" moved from a String flag parsed
|
||||
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
|
||||
// same [2,4] domain check in RunE.
|
||||
//
|
||||
// This is admissible because the historical set of *successful* invocations
|
||||
// is a subset of the new one, not merely similar to it:
|
||||
//
|
||||
// - Old: the value had to parse as base-10 and land in [2,4]. That is
|
||||
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
|
||||
// ("+3", "03", "003", ...).
|
||||
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
|
||||
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
|
||||
// resolves to the same number under base 0, so no historical success
|
||||
// becomes a failure.
|
||||
// - Base 0 additionally accepts "0x3" and friends, which the old parser
|
||||
// rejected. That widens the accepted set; it cannot break a caller who
|
||||
// was already succeeding.
|
||||
// - Values outside [2,4] and non-numeric values still fail. Only the
|
||||
// failure *message and timing* move, from RunE to flag parsing.
|
||||
//
|
||||
// The flag's default value changes from "" to "0" as an unavoidable
|
||||
// consequence of the type. Neither gate compares defaults, and a default is
|
||||
// not reachable by a caller here because RunE requires the flag to be
|
||||
// explicitly set, so this is not a contract change.
|
||||
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChange reports whether this exact command, flag and direction
|
||||
// is a reviewed migration. Callers must additionally establish that the rest of
|
||||
// the flag's contract is unchanged before skipping the failure.
|
||||
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
|
||||
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
|
||||
CommandPath: commandPath,
|
||||
Flag: flag,
|
||||
From: from,
|
||||
To: to,
|
||||
}]
|
||||
return reviewed
|
||||
}
|
||||
|
||||
// flagContractOtherwiseChanged reports whether anything this gate checks about a
|
||||
// flag, other than its type, differs between the two revisions. A reviewed type
|
||||
// migration is only accepted when this returns false, so the exemption cannot
|
||||
// carry an unrelated regression in behind it.
|
||||
//
|
||||
// Only the fields compareEffectiveFlags actually enforces are compared. Default
|
||||
// is deliberately excluded: a string → int migration always changes it ("" to
|
||||
// "0"), so including it would make every entry in the table dead. Deprecated is
|
||||
// excluded for the same reason it is not a blocking change on its own.
|
||||
func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
return true
|
||||
}
|
||||
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
|
||||
return true
|
||||
}
|
||||
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
|
||||
return true
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
return true
|
||||
}
|
||||
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -22,11 +22,16 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
const SchemaVersion = 2
|
||||
|
||||
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
|
||||
// between a retained compatibility flag and an exact canonical Cobra flag.
|
||||
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
|
||||
|
||||
var (
|
||||
// Framework-owned command subtrees are deliberately excluded. They are
|
||||
@@ -80,6 +85,12 @@ type Flag struct {
|
||||
Required bool `json:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
AliasOf string `json:"alias_of,omitempty"`
|
||||
|
||||
// Capture cannot return an error, so it preserves malformed or non-framework
|
||||
// relation evidence here for Validate/Write to reject. Valid snapshots never
|
||||
// serialize this implementation detail.
|
||||
malformedAliasEvidence bool
|
||||
}
|
||||
|
||||
// Capture walks root without rendering help or executing any command. Both
|
||||
@@ -193,6 +204,9 @@ func (s Snapshot) Validate() error {
|
||||
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFlagAliases(command); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag {
|
||||
if flag == nil || excludedFlags[flag.Name] {
|
||||
return
|
||||
}
|
||||
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
|
||||
flags = append(flags, Flag{
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
AliasOf: aliasOf,
|
||||
malformedAliasEvidence: malformedAliasEvidence,
|
||||
})
|
||||
})
|
||||
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
|
||||
return flags
|
||||
}
|
||||
|
||||
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
|
||||
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
|
||||
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
|
||||
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
|
||||
return "", false
|
||||
}
|
||||
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
|
||||
origin != runtimeannotate.FlagAliasOriginCorecmdV1
|
||||
return aliasOf, malformed
|
||||
}
|
||||
|
||||
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
|
||||
values, exists := flag.Annotations[key]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
|
||||
return "", true
|
||||
}
|
||||
return values[0], false
|
||||
}
|
||||
|
||||
func isRequired(flag *pflag.Flag) bool {
|
||||
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
|
||||
if value == "true" {
|
||||
@@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFlagAliases(command Command) error {
|
||||
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
|
||||
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
|
||||
for _, flag := range command.LocalFlags {
|
||||
flags[flag.Name] = flag
|
||||
ordered = append(ordered, flag)
|
||||
}
|
||||
for _, flag := range command.InheritedFlags {
|
||||
if _, exists := flags[flag.Name]; exists {
|
||||
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
|
||||
}
|
||||
flags[flag.Name] = flag
|
||||
ordered = append(ordered, flag)
|
||||
}
|
||||
|
||||
for _, flag := range ordered {
|
||||
if flag.malformedAliasEvidence {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s must declare one exact %q value and framework origin %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
FlagAliasOfAnnotation,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
if flag.AliasOf == "" {
|
||||
continue
|
||||
}
|
||||
if !isExactFlagName(flag.AliasOf) {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s has non-exact alias_of target %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if flag.AliasOf == flag.Name {
|
||||
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
|
||||
}
|
||||
target, exists := flags[flag.AliasOf]
|
||||
if !exists {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s aliases missing flag --%s",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if target.AliasOf != "" {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s aliases --%s, which is itself an alias",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if flag.Type != target.Type {
|
||||
return fmt.Errorf(
|
||||
"command %q alias flags --%s and --%s have different types %q and %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
flag.Type,
|
||||
target.Type,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
|
||||
|
||||
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
input := bytes.NewBufferString(`{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
|
||||
"commands": [],
|
||||
"future_field": true
|
||||
@@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
|
||||
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
|
||||
t.Fatalf(
|
||||
"flag alias annotation key drifted: snapshot=%q runtime=%q",
|
||||
FlagAliasOfAnnotation,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{
|
||||
Name: "message-id",
|
||||
Usage: "message ID",
|
||||
Aliases: []string{"open-conversation-id"},
|
||||
}})
|
||||
root.AddCommand(leaf)
|
||||
|
||||
snapshot := Capture(root)
|
||||
flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"]
|
||||
if flag.AliasOf != "message-id" {
|
||||
t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := Write(&encoded, snapshot); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"]
|
||||
if decodedFlag.AliasOf != "message-id" {
|
||||
t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) {
|
||||
for _, origin := range []string{"", "handwritten.alias.v1"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("message-id", "", "canonical message ID")
|
||||
leaf.Flags().String("legacy-id", "", "legacy message ID")
|
||||
legacy := leaf.Flags().Lookup("legacy-id")
|
||||
legacy.Annotations = map[string][]string{
|
||||
FlagAliasOfAnnotation: {"message-id"},
|
||||
}
|
||||
if origin != "" {
|
||||
legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin}
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
if err := Capture(root).Validate(); err == nil {
|
||||
t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("message-id", "", "canonical message ID")
|
||||
leaf.Flags().String("other-id", "", "other ID")
|
||||
leaf.Flags().String("legacy-id", "", "legacy ID")
|
||||
legacy := leaf.Flags().Lookup("legacy-id")
|
||||
legacy.Annotations = map[string][]string{
|
||||
FlagAliasOfAnnotation: {"message-id", "other-id"},
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
snapshot := Capture(root)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatal("Validate accepted multiple non-empty alias annotation values")
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := Write(&encoded, snapshot); err == nil {
|
||||
t.Fatal("Write accepted multiple non-empty alias annotation values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
aliasOf string
|
||||
}{
|
||||
{name: "self", aliasOf: "legacy-id"},
|
||||
{name: "leading dashes", aliasOf: "--message-id"},
|
||||
{name: "non exact", aliasOf: "message id"},
|
||||
{name: "missing target", aliasOf: "missing-id"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithFlagScopes(
|
||||
"dws send",
|
||||
[]Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: test.aliasOf},
|
||||
{Name: "message-id", Type: "string"},
|
||||
},
|
||||
nil,
|
||||
),
|
||||
)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted alias_of %q", test.aliasOf)
|
||||
}
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
if _, err := Read(bytes.NewReader(encoded)); err == nil {
|
||||
t.Fatalf("Read accepted alias_of %q", test.aliasOf)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
local []Flag
|
||||
inherited []Flag
|
||||
}{
|
||||
{
|
||||
name: "alias chain",
|
||||
local: []Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: "middle-id"},
|
||||
{Name: "middle-id", Type: "string", AliasOf: "message-id"},
|
||||
{Name: "message-id", Type: "string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different types",
|
||||
local: []Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: "message-id"},
|
||||
{Name: "message-id", Type: "int"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same flag in local and inherited scopes",
|
||||
local: []Flag{{Name: "legacy-id", Type: "string"}},
|
||||
inherited: []Flag{{Name: "legacy-id", Type: "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithFlagScopes("dws send", test.local, test.inherited),
|
||||
)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(
|
||||
|
||||
@@ -59,6 +59,51 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// resolveNamedList finds a list in a response envelope without conflating a
|
||||
// missing/malformed collection with a legitimate empty array. Some AITable MCP
|
||||
// versions use a direct collection key while others wrap it one level below
|
||||
// data/result, so all reviewed shapes are accepted but at least one explicit
|
||||
// JSON array must be present.
|
||||
func resolveNamedList(operation string, data map[string]any, outerKeys, innerKeys []string) ([]any, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("%s response is nil and does not establish a list result", operation)
|
||||
}
|
||||
invalid := make([]string, 0)
|
||||
for _, key := range outerKeys {
|
||||
value, exists := data[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if list, ok := value.([]any); ok {
|
||||
return list, nil
|
||||
}
|
||||
envelope, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
invalid = append(invalid, fmt.Sprintf("%s=%T", key, value))
|
||||
continue
|
||||
}
|
||||
foundInner := false
|
||||
for _, innerKey := range innerKeys {
|
||||
innerValue, innerExists := envelope[innerKey]
|
||||
if !innerExists {
|
||||
continue
|
||||
}
|
||||
foundInner = true
|
||||
if list, ok := innerValue.([]any); ok {
|
||||
return list, nil
|
||||
}
|
||||
invalid = append(invalid, fmt.Sprintf("%s.%s=%T", key, innerKey, innerValue))
|
||||
}
|
||||
if !foundInner {
|
||||
invalid = append(invalid, fmt.Sprintf("%s has no recognized collection", key))
|
||||
}
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
return nil, fmt.Errorf("%s response has no valid list collection (%v)", operation, invalid)
|
||||
}
|
||||
return nil, fmt.Errorf("%s response is missing its list collection", operation)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// base: Base 管理(server: aitable)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -116,7 +161,10 @@ var BaseList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bases := baseListProject(data)
|
||||
bases, err := baseListProject("list_bases", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
|
||||
},
|
||||
}
|
||||
@@ -124,15 +172,19 @@ var BaseList = shortcut.Shortcut{
|
||||
// baseListProject reshapes list_bases / search_bases responses into a clean
|
||||
// {baseId, baseName} list — clean output projection. Both the list
|
||||
// container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func baseListProject(data map[string]any) []map[string]any {
|
||||
raw := baseListResolveList(data)
|
||||
// candidate keys. An explicitly returned empty array is a successful empty
|
||||
// result; a missing/malformed collection is a protocol error and must not be
|
||||
// reported as count=0.
|
||||
func baseListProject(operation string, data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := baseListResolveList(operation, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("%s response item %d must be an object, got %T", operation, index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := baseListFirst(m, "baseId", "base_id", "id"); ok {
|
||||
@@ -141,36 +193,20 @@ func baseListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := baseListFirst(m, "baseName", "base_name", "name", "title"); ok {
|
||||
row["baseName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["baseId"]; !ok {
|
||||
return nil, fmt.Errorf("%s response item %d is missing baseId", operation, index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// baseListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope.
|
||||
func baseListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"bases", "result", "data", "list", "items", "records"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"bases", "list", "items", "result", "data", "records"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func baseListResolveList(operation string, data map[string]any) ([]any, error) {
|
||||
return resolveNamedList(operation, data,
|
||||
[]string{"bases", "result", "data", "list", "items", "records"},
|
||||
[]string{"bases", "list", "items", "result", "data", "records"})
|
||||
}
|
||||
|
||||
// baseListFirst returns the first present candidate key's value.
|
||||
@@ -230,7 +266,10 @@ var BaseSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bases := baseListProject(data)
|
||||
bases, err := baseListProject("search_bases", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
|
||||
},
|
||||
}
|
||||
@@ -339,7 +378,7 @@ var BaseDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "待删除 Base ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +base-delete --base-id BASE_ID --yes`},
|
||||
Tips: []string{`dws aitable +base-delete --base-id BASE_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"baseId": rt.Str("base-id")}
|
||||
if rt.Changed("reason") {
|
||||
@@ -474,7 +513,7 @@ var TableDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "待删除 Table ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +table-delete --base-id B --table-id T --yes`},
|
||||
Tips: []string{`dws aitable +table-delete --base-id B --table-id T`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -601,7 +640,7 @@ var FieldDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "field-id", Type: shortcut.FlagString, Desc: "待删除字段 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F --yes`},
|
||||
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_field", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -704,26 +743,16 @@ var RecordUpdate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-update",
|
||||
Product: serverMain,
|
||||
Description: "批量更新记录(每条含 recordId 与 cells),单次最多 100 条",
|
||||
Intent: "当你要批量修改已有行的字段值(如把一批任务标为已完成,每条须带 recordId)时使用;会实际覆盖对应单元格,单次最多 100 条。",
|
||||
Description: "批量更新记录,自动按 100 条分片并逐批读回验证",
|
||||
Intent: "当你要批量修改已有行的字段值(每条须带 recordId)时使用;自动按 100 条分片,任一批失败或读回不一致即非零退出并给出 nextOffset。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,如 '[{\"recordId\":\"rec\",\"cells\":{...}}]'", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
recs, err := parseJSONAny("records", rt.Str("records"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("update_records", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"records": recs,
|
||||
})
|
||||
},
|
||||
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
|
||||
Execute: executeRecordUpdateBatches,
|
||||
}
|
||||
|
||||
// RecordDelete 删除行记录(delete_records)。
|
||||
@@ -731,22 +760,16 @@ var RecordDelete = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-delete",
|
||||
Product: serverMain,
|
||||
Description: "批量删除记录(不可逆),单次最多 100 条",
|
||||
Intent: "当你确认要批量删除若干行记录时使用;不可逆,按 recordId 列表删除,单次最多 100 条。",
|
||||
Description: "批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在",
|
||||
Intent: "当你确认要批量删除若干行记录时使用;自动按 100 条分片,只有每批全部 recordId 读回均不存在才判定成功,否则非零退出并给出 nextOffset。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2 --yes`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_records", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"recordIds": rt.StrSlice("record-ids"),
|
||||
})
|
||||
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔;最多 10000 个唯一 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2`},
|
||||
Execute: executeRecordDeleteBatches,
|
||||
}
|
||||
|
||||
// RecordQueryEmpty 查询空行(query_empty_records,server: aitable-helper)。
|
||||
@@ -919,26 +942,16 @@ var RecordUpsert = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-upsert",
|
||||
Product: serverHelper,
|
||||
Description: "按 records 是否带 recordId 自动拆分 create / update",
|
||||
Intent: "当你有一批数据、其中部分是新增部分是更新、不想自己区分时使用;按记录是否带 recordId 自动拆分为创建或更新并实际写入,单次最多 100 条。",
|
||||
Description: "按 recordId 自动拆分 create/update,按 100 条分片并读回验证",
|
||||
Intent: "当一批数据中部分新增、部分更新时使用;自动按 recordId 拆分并以 100 条为批次写入,创建必须返回新 recordId,全部批次都需读回验证。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,单次最多 100 条", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
recs, err := parseJSONAny("records", rt.Str("records"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("record_upsert", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"records": recs,
|
||||
})
|
||||
},
|
||||
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
|
||||
Execute: executeRecordUpsertBatches,
|
||||
}
|
||||
|
||||
// RecordPrimaryDocGet 查询记录主键文档(get_primary_doc,server: aitable-helper)。
|
||||
@@ -1047,7 +1060,10 @@ var TemplateSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templates := templateSearchProject(data)
|
||||
templates, err := templateSearchProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
|
||||
},
|
||||
}
|
||||
@@ -1055,15 +1071,17 @@ var TemplateSearch = shortcut.Shortcut{
|
||||
// templateSearchProject reshapes the raw search_templates response into a clean
|
||||
// {templateId, templateName} list — clean output projection. Both
|
||||
// the list container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func templateSearchProject(data map[string]any) []map[string]any {
|
||||
raw := templateSearchResolveList(data)
|
||||
// candidate keys. Only an explicit array can establish a successful result.
|
||||
func templateSearchProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := templateSearchResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("search_templates response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := templateSearchFirst(m, "templateId", "template_id", "id"); ok {
|
||||
@@ -1072,37 +1090,21 @@ func templateSearchProject(data map[string]any) []map[string]any {
|
||||
if v, ok := templateSearchFirst(m, "templateName", "template_name", "name", "title"); ok {
|
||||
row["templateName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["templateId"]; !ok {
|
||||
return nil, fmt.Errorf("search_templates response item %d is missing templateId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// templateSearchResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level under a
|
||||
// common envelope key.
|
||||
func templateSearchResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"templates", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"templates", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func templateSearchResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("search_templates", data,
|
||||
[]string{"templates", "result", "data", "list", "items"},
|
||||
[]string{"templates", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// templateSearchFirst returns the first present candidate key's value.
|
||||
@@ -1198,7 +1200,10 @@ var ViewGet = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
views := viewGetProject(data)
|
||||
views, err := viewGetProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(views), "views": views})
|
||||
},
|
||||
}
|
||||
@@ -1206,15 +1211,17 @@ var ViewGet = shortcut.Shortcut{
|
||||
// viewGetProject reshapes the raw get_views response into a clean
|
||||
// {viewId, viewName, viewType} list — clean output projection. Both
|
||||
// the list container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func viewGetProject(data map[string]any) []map[string]any {
|
||||
raw := viewGetResolveList(data)
|
||||
// candidate keys. Only an explicit array can establish a successful result.
|
||||
func viewGetProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := viewGetResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("get_views response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := viewGetFirst(m, "viewId", "view_id", "id"); ok {
|
||||
@@ -1226,37 +1233,21 @@ func viewGetProject(data map[string]any) []map[string]any {
|
||||
if v, ok := viewGetFirst(m, "viewType", "view_type", "type"); ok {
|
||||
row["viewType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["viewId"]; !ok {
|
||||
return nil, fmt.Errorf("get_views response item %d is missing viewId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// viewGetResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope
|
||||
// key.
|
||||
func viewGetResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"views", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"views", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func viewGetResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("get_views", data,
|
||||
[]string{"views", "result", "data", "list", "items"},
|
||||
[]string{"views", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// viewGetFirst returns the first present candidate key's value.
|
||||
@@ -1355,7 +1346,7 @@ var ViewDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "view-id", Type: shortcut.FlagString, Desc: "待删除 View ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V --yes`},
|
||||
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_view", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -1668,23 +1659,28 @@ var FormList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forms := formListProject(data)
|
||||
forms, err := formListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
|
||||
},
|
||||
}
|
||||
|
||||
// formListProject reshapes the raw list_form_views response into a clean form
|
||||
// view list ({viewId, viewName, viewType}) — clean output projection.
|
||||
// Both the list container and the per-item field names are probed
|
||||
// defensively across candidate keys, so an empty/unknown shape yields an empty
|
||||
// list rather than a crash or fabricated data.
|
||||
func formListProject(data map[string]any) []map[string]any {
|
||||
raw := formListResolveList(data)
|
||||
// Both the list container and the per-item field names are probed defensively
|
||||
// across candidate keys. Only an explicit array can establish success.
|
||||
func formListProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := formListResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("list_form_views response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := formListFirst(m, "viewId", "view_id", "id"); ok {
|
||||
@@ -1696,36 +1692,20 @@ func formListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := formListFirst(m, "viewType", "view_type", "type"); ok {
|
||||
row["viewType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["viewId"]; !ok {
|
||||
return nil, fmt.Errorf("list_form_views response item %d is missing viewId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// formListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope.
|
||||
func formListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"forms", "views", "formViews", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"forms", "views", "formViews", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func formListResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("list_form_views", data,
|
||||
[]string{"forms", "views", "formViews", "result", "data", "list", "items"},
|
||||
[]string{"forms", "views", "formViews", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// formListFirst returns the first present candidate key's value.
|
||||
@@ -1751,7 +1731,7 @@ var FormDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V --yes`},
|
||||
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_form_view", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2013,7 +1993,7 @@ var WorkflowDisable = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W --yes`},
|
||||
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("disable_workflow", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2069,23 +2049,27 @@ var WorkflowList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workflows := workflowListProject(data)
|
||||
workflows, err := workflowListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(workflows), "workflows": workflows})
|
||||
},
|
||||
}
|
||||
|
||||
// workflowListProject reshapes the raw list_workflows response into a clean
|
||||
// workflow list ({workflowId, name, status/enabled}) — output-projection
|
||||
// clean output projection. Both the list container and the per-item field names are
|
||||
// probed defensively across candidate keys, so an empty/unknown shape yields an
|
||||
// empty list rather than a crash or fabricated data.
|
||||
func workflowListProject(data map[string]any) []map[string]any {
|
||||
raw := workflowListResolveList(data)
|
||||
// clean output projection. Only an explicit array can establish success.
|
||||
func workflowListProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := workflowListResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
|
||||
@@ -2097,37 +2081,21 @@ func workflowListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := workflowListFirst(m, "status", "enabled", "isEnabled", "state"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["workflowId"]; !ok {
|
||||
return nil, fmt.Errorf("list_workflows response item %d is missing workflowId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workflowListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level under a
|
||||
// common envelope.
|
||||
func workflowListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"workflows", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"workflows", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func workflowListResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("list_workflows", data,
|
||||
[]string{"workflows", "result", "data", "list", "items"},
|
||||
[]string{"workflows", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// workflowListFirst returns the first present candidate key's value.
|
||||
@@ -2281,7 +2249,7 @@ var DashboardDelete = shortcut.Shortcut{
|
||||
{Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D --yes`},
|
||||
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2512,7 +2480,7 @@ var ChartDelete = shortcut.Shortcut{
|
||||
{Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C --yes`},
|
||||
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2744,7 +2712,7 @@ var AdvpermDisable = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +advperm-disable --base-id B --yes`},
|
||||
Tips: []string{`dws aitable +advperm-disable --base-id B`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("set_advanced_permission", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2908,7 +2876,7 @@ var RoleDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID(数字 long 字符串)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +role-delete --base-id B --role-id R --yes`},
|
||||
Tips: []string{`dws aitable +role-delete --base-id B --role-id R`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_role", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2986,7 +2954,7 @@ var SectionDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +section-delete --base-id B --section-id S --yes`},
|
||||
Tips: []string{`dws aitable +section-delete --base-id B --section-id S`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_section", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -3131,7 +3099,7 @@ var SectionMoveNode = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedAITableShortcutContracts(
|
||||
BaseList,
|
||||
BaseSearch,
|
||||
BaseGet,
|
||||
@@ -3139,7 +3107,10 @@ func init() {
|
||||
BaseUpdate,
|
||||
BaseDelete,
|
||||
BaseCopy,
|
||||
BaseSchemaSnapshot,
|
||||
BaseBootstrap,
|
||||
TableGet,
|
||||
TableCopy,
|
||||
TableUpdate,
|
||||
TableDelete,
|
||||
FieldGet,
|
||||
@@ -3152,12 +3123,17 @@ func init() {
|
||||
RecordHistoryList,
|
||||
RecordShareURL,
|
||||
RecordUpsert,
|
||||
RecordUpsertByKey,
|
||||
RecordBulkPatch,
|
||||
RecordPrimaryDocGet,
|
||||
RecordPrimaryDocCreate,
|
||||
TemplateSearch,
|
||||
AttachmentUpload,
|
||||
AttachmentPut,
|
||||
AttachmentRemove,
|
||||
ViewGet,
|
||||
ViewUpdate,
|
||||
ViewPresetApply,
|
||||
ViewDuplicate,
|
||||
ViewDelete,
|
||||
ViewGetLock,
|
||||
@@ -3179,6 +3155,7 @@ func init() {
|
||||
WorkflowDisable,
|
||||
WorkflowGet,
|
||||
WorkflowList,
|
||||
WorkflowDeploy,
|
||||
DashboardConfigExample,
|
||||
DashboardGet,
|
||||
DashboardUpdate,
|
||||
@@ -3209,5 +3186,6 @@ func init() {
|
||||
SectionListEmpty,
|
||||
SectionListNodes,
|
||||
SectionMoveNode,
|
||||
)
|
||||
URLResolve,
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const maxAttachmentFileSize = int64(100 * 1024 * 1024)
|
||||
|
||||
func rejectAttachmentUploadRedirect(*http.Request, []*http.Request) error {
|
||||
// Upload credentials are issued for one exact endpoint. Do not let a 3xx
|
||||
// response redirect local file bytes or the credential to another host or
|
||||
// to a URL outside validateAttachmentUploadURL's transport policy.
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
var attachmentHTTPDo = (&http.Client{CheckRedirect: rejectAttachmentUploadRedirect}).Do
|
||||
|
||||
var AttachmentPut = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+attachment-put",
|
||||
Product: serverMain,
|
||||
Description: "准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证",
|
||||
Intent: "当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: aitableCompositeContract(
|
||||
"+attachment-put",
|
||||
"准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证",
|
||||
"当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。",
|
||||
"只申请上传凭证用 attachment upload;不要用 drive fileId;现有附件读回缺 fileToken 时无法安全 append,只能 replace 或停止",
|
||||
`dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true},
|
||||
{Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true},
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||||
{Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "append 保留现有附件;replace 整体替换", Enum: []string{"append", "replace"}},
|
||||
{Name: "mime-type", Type: shortcut.FlagString, Desc: "覆盖自动推断的 MIME type(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeAttachmentPut(rt)
|
||||
},
|
||||
}
|
||||
|
||||
var AttachmentRemove = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+attachment-remove",
|
||||
Product: serverMain,
|
||||
Description: "从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证",
|
||||
Intent: "当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: aitableCompositeContract(
|
||||
"+attachment-remove",
|
||||
"从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证",
|
||||
"当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。",
|
||||
"读回仅有下载 URL 而没有剩余附件 fileToken 时,DWS 无法在覆盖式写接口上安全对齐逐项删除;此时只能 --clear-all 或停止",
|
||||
`dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true},
|
||||
{Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true},
|
||||
{Name: "remove-name", Type: shortcut.FlagString, Desc: "移除精确文件名的所有匹配项;与 --clear-all 二选一"},
|
||||
{Name: "clear-all", Type: shortcut.FlagBool, Desc: "清空该字段全部附件;与 --remove-name 二选一"},
|
||||
},
|
||||
Tips: []string{`dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeAttachmentRemove(rt)
|
||||
},
|
||||
}
|
||||
|
||||
func executeAttachmentPut(rt *shortcut.RuntimeContext) error {
|
||||
file, info, mimeType, err := openAttachmentFile(rt.Str("file"), rt.Str("mime-type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id")
|
||||
record, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = record
|
||||
desired := make([]any, 0, len(existing)+1)
|
||||
if rt.Str("mode") == "append" {
|
||||
for index, item := range existing {
|
||||
token := attachmentToken(item)
|
||||
if token == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("现有附件[%d]读回缺少 fileToken,覆盖式写接口无法安全 append;请用 --mode replace 或保留原数据后人工处理", index),
|
||||
apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
desired = append(desired, map[string]any{"fileToken": token})
|
||||
}
|
||||
}
|
||||
result := newCompositeResult("attachment_put")
|
||||
result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "fileName": info.Name(), "size": info.Size(), "mode": rt.Str("mode")}
|
||||
result.Plan = []compositeStep{
|
||||
{Index: 1, Name: "prepare attachment upload", Tool: "prepare_attachment_upload", Status: "planned"},
|
||||
{Index: 2, Name: "PUT file bytes", Tool: "HTTP PUT", Status: "planned"},
|
||||
{Index: 3, Name: "write and verify attachment cell", Tool: "update_records", Status: "planned"},
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
prepareData, err := rt.CallMCPWriteDataStrict(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType})
|
||||
if err != nil {
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
uploadURL := findStringByKeys(prepareData, "uploadUrl")
|
||||
fileToken := findStringByKeys(prepareData, "fileToken")
|
||||
if uploadURL == "" || fileToken == "" {
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, fmt.Errorf("prepare_attachment_upload response is missing uploadUrl or fileToken"), false)
|
||||
}
|
||||
if err := validateAttachmentUploadURL(uploadURL); err != nil {
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
// validateAttachmentUploadURL has already rejected every URL shape for
|
||||
// which NewRequestWithContext can fail; PUT is a fixed valid method.
|
||||
request, _ := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file)
|
||||
request.Header.Set("Content-Type", mimeType)
|
||||
request.ContentLength = info.Size()
|
||||
response, err := attachmentHTTPDo(request)
|
||||
if err != nil {
|
||||
result.Status = "partial_success"
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken})
|
||||
return compositeError(result, fmt.Errorf("attachment HTTP PUT failed: %w", err), false)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
closeErr := response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 || closeErr != nil {
|
||||
result.Status = "partial_success"
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken, "httpStatus": response.StatusCode})
|
||||
return compositeError(result, fmt.Errorf("attachment HTTP PUT returned status %d (close error: %v)", response.StatusCode, closeErr), false)
|
||||
}
|
||||
desired = append(desired, map[string]any{"fileToken": fileToken})
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "HTTP PUT", "fileToken": fileToken, "fileName": info.Name(), "size": info.Size()})
|
||||
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID,
|
||||
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
|
||||
})
|
||||
_, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
|
||||
if verifyErr == nil {
|
||||
verifyErr = verifyAttachmentPut(readBack, len(desired), fileToken, info.Name(), info.Size())
|
||||
}
|
||||
if verifyErr != nil {
|
||||
result.Status = "partial_success"
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "update_records", "recordId": recordIDValue, "fieldId": fieldID})
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error())
|
||||
}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
result.CompletedCount = 1
|
||||
result.Verification = map[string]any{"status": "verified", "attachmentCount": len(readBack), "fileName": info.Name(), "size": info.Size()}
|
||||
result.Result = map[string]any{"fileToken": fileToken, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType, "attachmentCount": len(readBack)}
|
||||
if writeErr != nil {
|
||||
result.Status = "recovered"
|
||||
result.Warnings = append(result.Warnings, "record write response was an error, but the attachment cell was proven by read-back")
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func executeAttachmentRemove(rt *shortcut.RuntimeContext) error {
|
||||
removeName := strings.TrimSpace(rt.Str("remove-name"))
|
||||
clearAll := rt.Bool("clear-all")
|
||||
if (removeName == "") == !clearAll {
|
||||
return apperrors.NewValidation("必须且只能提供 --remove-name 或 --clear-all")
|
||||
}
|
||||
baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id")
|
||||
_, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desired := make([]any, 0, len(existing))
|
||||
removed := 0
|
||||
if clearAll {
|
||||
removed = len(existing)
|
||||
} else {
|
||||
for index, item := range existing {
|
||||
if attachmentName(item) == removeName {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
token := attachmentToken(item)
|
||||
if token == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("剩余附件[%d]读回缺少 fileToken,覆盖式写接口无法安全保留后删除;服务端当前不支持按附件增量删除", index),
|
||||
apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
desired = append(desired, map[string]any{"fileToken": token})
|
||||
}
|
||||
}
|
||||
result := newCompositeResult("attachment_remove")
|
||||
result.RequestedCount = removed
|
||||
result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "removeName": removeName, "clearAll": clearAll}
|
||||
if removed == 0 {
|
||||
result.Status = "unchanged"
|
||||
result.Executed = false
|
||||
result.Verification = map[string]any{"status": "verified", "attachmentCount": len(existing), "removedCount": 0}
|
||||
return rt.Output(result)
|
||||
}
|
||||
result.Plan = []compositeStep{{Index: 1, Name: "replace attachment cell without selected items", Tool: "update_records", Status: "planned", Count: removed}}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID,
|
||||
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
|
||||
})
|
||||
_, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
|
||||
if verifyErr == nil && len(readBack) != len(desired) {
|
||||
verifyErr = fmt.Errorf("attachment read-back count is %d, want %d", len(readBack), len(desired))
|
||||
}
|
||||
if verifyErr == nil && removeName != "" {
|
||||
for _, item := range readBack {
|
||||
if attachmentName(item) == removeName {
|
||||
verifyErr = fmt.Errorf("attachment %q is still present after removal", removeName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if verifyErr != nil {
|
||||
result.Status = "unknown"
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error())
|
||||
}
|
||||
return compositeError(result, verifyErr, true)
|
||||
}
|
||||
result.CompletedCount = removed
|
||||
result.Verification = map[string]any{"status": "verified", "removedCount": removed, "attachmentCount": len(readBack)}
|
||||
result.Result = map[string]any{"removedCount": removed, "attachmentCount": len(readBack)}
|
||||
if writeErr != nil {
|
||||
result.Status = "recovered"
|
||||
result.Warnings = append(result.Warnings, "record write response was an error, but removal was proven by read-back")
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func openAttachmentFile(path, overrideMIME string) (*os.File, os.FileInfo, string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, "", apperrors.NewValidation("无法打开 --file: " + err.Error())
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxAttachmentFileSize {
|
||||
file.Close()
|
||||
return nil, nil, "", apperrors.NewValidation(fmt.Sprintf("--file 必须是 1..%d 字节的普通文件", maxAttachmentFileSize))
|
||||
}
|
||||
mimeType := strings.TrimSpace(overrideMIME)
|
||||
if mimeType == "" {
|
||||
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(info.Name())))
|
||||
}
|
||||
if mimeType == "" {
|
||||
header := make([]byte, 512)
|
||||
read, _ := file.Read(header)
|
||||
mimeType = http.DetectContentType(header[:read])
|
||||
_, _ = file.Seek(0, io.SeekStart)
|
||||
}
|
||||
return file, info, mimeType, nil
|
||||
}
|
||||
|
||||
func validateAttachmentUploadURL(raw string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return fmt.Errorf("prepare_attachment_upload returned an invalid uploadUrl")
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||
return fmt.Errorf("plaintext attachment uploadUrl is allowed only for loopback test servers")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAttachmentCell(rt *shortcut.RuntimeContext, baseID, tableID, recordIDValue, fieldID string) (map[string]any, []map[string]any, error) {
|
||||
records, err := queryRecordsByIDs(rt, baseID, tableID, []string{recordIDValue})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(records) != 1 || recordID(records[0]) != recordIDValue {
|
||||
return nil, nil, fmt.Errorf("attachment read-back returned %d exact records for %s, want 1", len(records), recordIDValue)
|
||||
}
|
||||
cells, ok := records[0]["cells"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("record %s is missing cells", recordIDValue)
|
||||
}
|
||||
raw, exists := cells[fieldID]
|
||||
if !exists || raw == nil {
|
||||
return records[0], []map[string]any{}, nil
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("attachment field %s is not an array", fieldID)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("attachment field %s item %d is not an object", fieldID, index)
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return records[0], out, nil
|
||||
}
|
||||
|
||||
func attachmentToken(item map[string]any) string {
|
||||
return stringValue(item, "fileToken", "file_token")
|
||||
}
|
||||
|
||||
func attachmentName(item map[string]any) string {
|
||||
return stringValue(item, "fileName", "filename", "name")
|
||||
}
|
||||
|
||||
func verifyAttachmentPut(actual []map[string]any, expectedCount int, token, name string, size int64) error {
|
||||
if len(actual) != expectedCount {
|
||||
return fmt.Errorf("attachment read-back count is %d, want %d", len(actual), expectedCount)
|
||||
}
|
||||
for _, item := range actual {
|
||||
if attachmentToken(item) == token {
|
||||
return nil
|
||||
}
|
||||
itemSize, sizeOK := numericInt64(item["size"])
|
||||
if attachmentName(item) == name && sizeOK && itemSize == size {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("attachment read-back does not identify uploaded token or file %s/%d", name, size)
|
||||
}
|
||||
|
||||
func numericInt64(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed), typed == float64(int64(typed))
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func writeAttachmentFixture(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "report.txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func attachmentRecordJSON(t *testing.T, fieldID string, attachments []any) string {
|
||||
t.Helper()
|
||||
return mustJSONText(t, map[string]any{"records": []any{map[string]any{
|
||||
"recordId": "record", "cells": map[string]any{fieldID: attachments},
|
||||
}}})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentPutPerformsHTTPAndReadBackE2E(t *testing.T) {
|
||||
filePath := writeAttachmentFixture(t, "actual attachment bytes")
|
||||
fileName := filepath.Base(filePath)
|
||||
uploaded := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if request.Method != http.MethodPut || request.Header.Get("Content-Type") != "text/plain" || string(body) != "actual attachment bytes" {
|
||||
t.Errorf("upload request = method:%s type:%s body:%q", request.Method, request.Header.Get("Content-Type"), body)
|
||||
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uploaded = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft1"})},
|
||||
{text: ""},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": fileName, "size": len("actual attachment bytes")}})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--mime-type", "text/plain", "--yes")
|
||||
if err != nil || !uploaded {
|
||||
t.Fatalf("attachment put = output:%q err:%v uploaded:%v", out, err, uploaded)
|
||||
}
|
||||
for _, want := range []string{`"status": "recovered"`, `"fileToken": "ft1"`, `"attachmentCount": 1`, `"status": "verified"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("attachment output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 4 || caller.calls[1].tool != "prepare_attachment_upload" || caller.calls[2].tool != "update_records" {
|
||||
t.Fatalf("attachment calls = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *testing.T) {
|
||||
filePath := writeAttachmentFixture(t, "bytes")
|
||||
t.Run("append stops before upload when existing tokens unavailable", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "old.pdf", "url": "https://download"}})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") {
|
||||
t.Fatalf("unsafe append = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP failure leaves no record write", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) }))
|
||||
defer server.Close()
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 2 || !strings.Contains(err.Error(), "partial_success") {
|
||||
t.Fatalf("HTTP failure = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Retryable {
|
||||
t.Fatalf("partially uploaded attachment must not be blindly retryable: %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepare response must carry both values", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})}, {text: `{"uploadUrl":"https://example.invalid"}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 2 {
|
||||
t.Fatalf("bad prepare = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentPutRejectsHTTPSRedirectToPlaintextNonLoopbackE2E(t *testing.T) {
|
||||
filePath := writeAttachmentFixture(t, "redirect-sensitive bytes")
|
||||
sourceHits := 0
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
sourceHits++
|
||||
w.Header().Set("Location", "http://example.com/upload")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.CheckRedirect = rejectAttachmentUploadRedirect
|
||||
testseam.Swap(t, &attachmentHTTPDo, client.Do)
|
||||
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft-redirect"})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" ||
|
||||
typed.Cause == nil || !strings.Contains(typed.Cause.Error(), "status 302") {
|
||||
t.Fatalf("redirect upload = output:%q err:%v", out, err)
|
||||
}
|
||||
if sourceHits != 1 || len(caller.calls) != 2 {
|
||||
t.Fatalf("redirect upload reached an unexpected target: sourceHits=%d calls=%#v", sourceHits, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) {
|
||||
t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "a.pdf", "url": "https://download"}})},
|
||||
{text: ""},
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "recovered"`) || !strings.Contains(out, `"removedCount": 1`) {
|
||||
t.Fatalf("clear attachments = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selective removal preserves tokenized remainder", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "remove-token", "filename": "remove.pdf"},
|
||||
map[string]any{"fileToken": "keep-token", "filename": "keep.pdf"},
|
||||
})},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "keep.pdf", "size": 10}})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"removedCount": 1`) {
|
||||
t.Fatalf("selective remove = output:%q err:%v", out, err)
|
||||
}
|
||||
written := caller.calls[1].args["records"].([]any)[0].(map[string]any)["cells"].(map[string]any)["field"].([]any)
|
||||
if len(written) != 1 || written[0].(map[string]any)["fileToken"] != "keep-token" {
|
||||
t.Fatalf("selective desired attachments = %#v", written)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveExplainsTokenBoundaryE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"filename": "remove.pdf", "url": "https://one"},
|
||||
map[string]any{"filename": "keep.pdf", "url": "https://two"},
|
||||
})}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") || !strings.Contains(err.Error(), "增量删除") {
|
||||
t.Fatalf("token boundary = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentPutRemainingOutcomesE2E(t *testing.T) {
|
||||
filePath := writeAttachmentFixture(t, "bytes")
|
||||
|
||||
t.Run("file open failure", func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filepath.Join(t.TempDir(), "missing"), "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("file open = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial read failure", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
|
||||
t.Fatalf("initial read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append dry run preserves token", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"file_token": "old-token", "filename": "old.txt"},
|
||||
})}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("append dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepare call failure", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{err: errors.New("prepare failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("prepare failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid upload URL", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: `{"uploadUrl":"http://example.com/upload","fileToken":"ft1"}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid upload URL = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP transport failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &attachmentHTTPDo, func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("transport failed")
|
||||
})
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: `{"uploadUrl":"https://uploads.example.test/put","fileToken":"ft1"}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("transport failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write and read-back both fail", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
defer server.Close()
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})},
|
||||
{err: errors.New("write reply failed")},
|
||||
{err: errors.New("verification read failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("verification failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveRemainingOutcomesE2E(t *testing.T) {
|
||||
run := func(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
|
||||
args = append(args, extra...)
|
||||
args = append(args, "--yes")
|
||||
return runAITableCompositeCLI(t, caller, "+attachment-remove", args...)
|
||||
}
|
||||
|
||||
t.Run("initial read failure", func(t *testing.T) {
|
||||
out, err := run(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}, "--clear-all")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
|
||||
t.Fatalf("initial read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unchanged selection", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
|
||||
})}}}
|
||||
out, err := run(t, caller, "--remove-name", "missing.pdf")
|
||||
if err != nil || !strings.Contains(out, `"status": "unchanged"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("unchanged = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
|
||||
})}}}
|
||||
out, err := run(t, caller, "--remove-name", "remove.pdf", "--dry-run")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back count mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "other.pdf"}})},
|
||||
}}
|
||||
out, err := run(t, caller, "--clear-all")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("count mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removed name remains", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
|
||||
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
|
||||
})},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "remove.pdf"}})},
|
||||
}}
|
||||
out, err := run(t, caller, "--remove-name", "remove.pdf")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("removed item remains = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write and verification fail", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
|
||||
{err: errors.New("write reply failed")},
|
||||
{err: errors.New("verification failed")},
|
||||
}}
|
||||
out, err := run(t, caller, "--clear-all")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("verification failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentParsingAndValidation(t *testing.T) {
|
||||
t.Run("file validation and MIME sniffing", func(t *testing.T) {
|
||||
if file, _, _, err := openAttachmentFile(filepath.Join(t.TempDir(), "missing"), ""); err == nil || file != nil {
|
||||
t.Fatalf("missing file must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
if file, _, _, err := openAttachmentFile(t.TempDir(), ""); err == nil || file != nil {
|
||||
t.Fatalf("directory must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
empty := filepath.Join(t.TempDir(), "empty")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file, _, _, err := openAttachmentFile(empty, ""); err == nil || file != nil {
|
||||
t.Fatalf("empty file must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
unknown := filepath.Join(t.TempDir(), "payload.unknown-extension")
|
||||
if err := os.WriteFile(unknown, []byte("plain text"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _, mimeType, err := openAttachmentFile(unknown, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if mimeType == "" {
|
||||
t.Fatal("sniffed MIME type is empty")
|
||||
}
|
||||
})
|
||||
|
||||
for name, raw := range map[string]string{
|
||||
"missing host": "https:///upload",
|
||||
"credentials": "https://user:pass@example.com/upload",
|
||||
"wrong scheme": "ftp://example.com/upload",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateAttachmentUploadURL(raw); err == nil {
|
||||
t.Fatalf("%q must be rejected", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{"http://localhost/upload", "http://127.0.0.1/upload", "https://example.com/upload"} {
|
||||
if err := validateAttachmentUploadURL(raw); err != nil {
|
||||
t.Fatalf("%q = %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := verifyAttachmentPut(nil, 1, "token", "name", 1); err == nil {
|
||||
t.Fatal("count mismatch must fail")
|
||||
}
|
||||
if err := verifyAttachmentPut([]map[string]any{{"fileToken": "token"}}, 1, "token", "name", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyAttachmentPut([]map[string]any{{"filename": "other", "size": 1}}, 1, "token", "name", 1); err == nil {
|
||||
t.Fatal("identity mismatch must fail")
|
||||
}
|
||||
for value, want := range map[any]bool{int(1): true, int64(1): true, "1": false} {
|
||||
_, ok := numericInt64(value)
|
||||
if ok != want {
|
||||
t.Fatalf("numericInt64(%T) = %v, want %v", value, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveSelectionValidationE2E(t *testing.T) {
|
||||
for name, extra := range map[string][]string{
|
||||
"neither": nil,
|
||||
"both": {"--remove-name", "a.pdf", "--clear-all"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
|
||||
args = append(args, extra...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-remove", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("selection validation = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentReadBackShapesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
step upsertByKeyStep
|
||||
want string
|
||||
}{
|
||||
{name: "query error", step: upsertByKeyStep{err: errors.New("query failed")}, want: "query failed"},
|
||||
{name: "wrong record", step: upsertByKeyStep{text: `{"records":[{"recordId":"other","cells":{}}]}`}, want: "exact records"},
|
||||
{name: "missing cells", step: upsertByKeyStep{text: `{"records":[{"recordId":"record"}]}`}, want: "missing cells"},
|
||||
{name: "non-array field", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":"bad"}}]}`}, want: "not an array"},
|
||||
{name: "non-object item", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":["bad"]}}]}`}, want: "not an object"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{tc.step}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("shape = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[{"recordId":"record","cells":{}}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "unchanged"`) {
|
||||
t.Fatalf("missing field = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var BaseSchemaSnapshot = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+base-schema-snapshot",
|
||||
Product: serverMain,
|
||||
Description: "读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应",
|
||||
Intent: "当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: aitableCompositeContract(
|
||||
"+base-schema-snapshot",
|
||||
"读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应",
|
||||
"当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。",
|
||||
"需要记录数据用 record query;只看 Base 目录用 base get;快照不会创建或修改资源",
|
||||
"dws aitable +base-schema-snapshot --base-id BASE_ID",
|
||||
),
|
||||
Flags: []shortcut.Flag{{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}},
|
||||
Tips: []string{`dws aitable +base-schema-snapshot --base-id BASE_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeBaseSchemaSnapshot(rt)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseBootstrap = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+base-bootstrap",
|
||||
Product: serverMain,
|
||||
Description: "一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用",
|
||||
Intent: "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: aitableCompositeContract(
|
||||
"+base-bootstrap",
|
||||
"一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用",
|
||||
"当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
|
||||
"已有 Base 只需新增一张表时用 table create;复制现有 Base 用 base copy;不要对失败请求盲目重试",
|
||||
`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "新 Base 名称", Required: true},
|
||||
{Name: "folder-id", Type: shortcut.FlagString, Desc: "目标知识库文件夹 ID(可选)"},
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID(可选)"},
|
||||
{Name: "tables", Type: shortcut.FlagString, Desc: `表结构 JSON 数组:[{'name':'任务','fields':[...]}]`, Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeBaseBootstrap(rt)
|
||||
},
|
||||
}
|
||||
|
||||
type bootstrapTable struct {
|
||||
Name string
|
||||
Fields []any
|
||||
}
|
||||
|
||||
func parseBootstrapTables(raw string) ([]bootstrapTable, error) {
|
||||
value, err := parseJSONAny("tables", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return nil, apperrors.NewValidation("--tables 必须是非空 JSON 数组")
|
||||
}
|
||||
if len(items) > 100 {
|
||||
return nil, apperrors.NewValidation("--tables 最多接受 100 张表")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]bootstrapTable, 0, len(items))
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index))
|
||||
}
|
||||
name := strings.TrimSpace(stringValue(object, "name"))
|
||||
if name == "" || seen[name] {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].name 必须非空且不能重复", index))
|
||||
}
|
||||
seen[name] = true
|
||||
fields := []any{}
|
||||
if rawFields, exists := object["fields"]; exists {
|
||||
var fieldsOK bool
|
||||
fields, fieldsOK = rawFields.([]any)
|
||||
if !fieldsOK {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index))
|
||||
}
|
||||
}
|
||||
for fieldIndex, field := range fields {
|
||||
object, ok := field.(map[string]any)
|
||||
if !ok || strings.TrimSpace(stringValue(object, "fieldName", "name")) == "" || strings.TrimSpace(stringValue(object, "type")) == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex))
|
||||
}
|
||||
}
|
||||
out = append(out, bootstrapTable{Name: name, Fields: fields})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeBaseSchemaSnapshot(rt *shortcut.RuntimeContext) error {
|
||||
baseID := rt.Str("base-id")
|
||||
base, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !deepContainsString(base, baseID) {
|
||||
return fmt.Errorf("get_base response does not identify requested baseId %s", baseID)
|
||||
}
|
||||
tables, found := findNamedObjectList(base, "tables", "tableList", "sheets")
|
||||
if !found {
|
||||
return fmt.Errorf("get_base response is missing the tables collection")
|
||||
}
|
||||
result := newCompositeResult("base_schema_snapshot")
|
||||
result.RequestedCount = len(tables)
|
||||
result.Resolved = map[string]any{"baseId": baseID}
|
||||
tableSnapshots := make([]any, 0, len(tables))
|
||||
for index, table := range tables {
|
||||
tableID := stringValue(table, "tableId", "sheetId", "id")
|
||||
if tableID == "" {
|
||||
return fmt.Errorf("get_base tables[%d] is missing tableId", index)
|
||||
}
|
||||
detail, err := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}})
|
||||
if err != nil || !deepContainsString(detail, tableID) {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("get_tables response does not identify tableId %s", tableID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
|
||||
if !found {
|
||||
return fmt.Errorf("get_fields response for %s is missing the fields collection", tableID)
|
||||
}
|
||||
viewsData, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
views, found := findNamedObjectList(viewsData, "views", "viewList")
|
||||
if !found {
|
||||
return fmt.Errorf("get_views response for %s is missing the views collection", tableID)
|
||||
}
|
||||
tableSnapshots = append(tableSnapshots, map[string]any{
|
||||
"tableId": tableID, "summary": table, "detail": detail, "fields": fields, "views": views,
|
||||
})
|
||||
result.CompletedCount = index + 1
|
||||
}
|
||||
result.Result = map[string]any{"base": base, "tables": tableSnapshots}
|
||||
result.Verification = map[string]any{"status": "verified", "tableCount": len(tables)}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
|
||||
tables, err := parseBootstrapTables(rt.Str("tables"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := newCompositeResult("base_bootstrap")
|
||||
result.RequestedCount = len(tables)
|
||||
result.Plan = append(result.Plan, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "planned"})
|
||||
for index, table := range tables {
|
||||
result.Plan = append(result.Plan, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "planned", Count: len(table.Fields), Arguments: map[string]any{"name": table.Name}})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
baseArgs := map[string]any{"baseName": rt.Str("name")}
|
||||
if rt.Changed("folder-id") {
|
||||
baseArgs["folderId"] = rt.Str("folder-id")
|
||||
}
|
||||
if rt.Changed("template-id") {
|
||||
baseArgs["templateId"] = rt.Str("template-id")
|
||||
}
|
||||
baseData, err := rt.CallMCPWriteDataStrict(serverMain, "create_base", baseArgs)
|
||||
if err != nil {
|
||||
result.Status = "unknown"
|
||||
result.FailedCount = len(tables)
|
||||
result.Checkpoint = map[string]any{"nextStep": "resolve whether base was created before retrying"}
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
baseID := findStringByKeys(baseData, "baseId")
|
||||
if baseID == "" {
|
||||
result.Status = "unknown"
|
||||
result.Result = baseData
|
||||
result.Checkpoint = map[string]any{"nextStep": "locate the created Base by exact name before retrying"}
|
||||
return compositeError(result, fmt.Errorf("create_base response is missing baseId"), false)
|
||||
}
|
||||
result.Resolved = map[string]any{"baseId": baseID}
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_base", "baseId": baseID})
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "completed", Result: baseData})
|
||||
baseRead, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
|
||||
if err != nil || !deepContainsString(baseRead, baseID) {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("get_base does not identify created baseId %s", baseID)
|
||||
}
|
||||
result.Status = "partial_success"
|
||||
result.Checkpoint = map[string]any{"nextTableIndex": 0, "baseId": baseID}
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
|
||||
createdTables := make([]any, 0, len(tables))
|
||||
for index, spec := range tables {
|
||||
initialEnd := minInt(15, len(spec.Fields))
|
||||
initialFields := spec.Fields[:initialEnd]
|
||||
createData, createErr := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
|
||||
"baseId": baseID, "tableName": spec.Name, "fields": initialFields,
|
||||
})
|
||||
tableID := findStringByKeys(createData, "tableId", "sheetId")
|
||||
if createErr != nil || tableID == "" {
|
||||
if createErr == nil {
|
||||
createErr = fmt.Errorf("create_table response for %q is missing tableId", spec.Name)
|
||||
}
|
||||
result.Status = "partial_success"
|
||||
result.CompletedCount = index
|
||||
result.FailedCount = len(tables) - index
|
||||
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID}
|
||||
return compositeError(result, createErr, false)
|
||||
}
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name})
|
||||
for offset := initialEnd; offset < len(spec.Fields); offset += 15 {
|
||||
end := minInt(offset+15, len(spec.Fields))
|
||||
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "fields": spec.Fields[offset:end],
|
||||
})
|
||||
if fieldErr != nil {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; checking final field state: %v", offset, fieldErr))
|
||||
}
|
||||
}
|
||||
detail, verifyErr := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}})
|
||||
if verifyErr != nil || !deepContainsString(detail, tableID) {
|
||||
if verifyErr == nil {
|
||||
verifyErr = fmt.Errorf("get_tables does not identify created tableId %s", tableID)
|
||||
}
|
||||
result.Status = "partial_success"
|
||||
result.CompletedCount = index
|
||||
result.FailedCount = len(tables) - index
|
||||
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "verify table and fields"}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
fieldsData, verifyErr := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
|
||||
if verifyErr != nil || !found || !containsAllFieldNames(fields, spec.Fields) {
|
||||
if verifyErr == nil {
|
||||
verifyErr = fmt.Errorf("field read-back for table %s does not contain the declared field set", tableID)
|
||||
}
|
||||
result.Status = "partial_success"
|
||||
result.CompletedCount = index
|
||||
result.FailedCount = len(tables) - index
|
||||
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "repair fields"}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
result.CompletedCount = index + 1
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "completed", Count: len(spec.Fields), Result: map[string]any{"tableId": tableID, "fieldCount": len(fields)}})
|
||||
createdTables = append(createdTables, map[string]any{"tableId": tableID, "name": spec.Name, "fieldCount": len(fields)})
|
||||
}
|
||||
result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableCount": len(createdTables)}
|
||||
result.Result = map[string]any{"baseId": baseID, "tables": createdTables}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func findNamedObjectList(value any, names ...string) ([]map[string]any, bool) {
|
||||
nameSet := map[string]bool{}
|
||||
for _, name := range names {
|
||||
nameSet[strings.ToLower(name)] = true
|
||||
}
|
||||
var walk func(any) ([]map[string]any, bool)
|
||||
walk = func(current any) ([]map[string]any, bool) {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for key, child := range object {
|
||||
if nameSet[strings.ToLower(key)] {
|
||||
items, ok := child.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
for _, child := range object {
|
||||
if found, ok := walk(child); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func stringValue(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findStringByKeys(value any, keys ...string) string {
|
||||
keySet := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
keySet[strings.ToLower(key)] = true
|
||||
}
|
||||
var walk func(any) string
|
||||
walk = func(current any) string {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
if keySet[strings.ToLower(key)] {
|
||||
if text, ok := child.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
if found := walk(child); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if found := walk(child); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func deepContainsString(value any, expected string) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, child := range typed {
|
||||
if deepContainsString(child, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if deepContainsString(child, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return typed == expected
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAllFieldNames(actual []map[string]any, expected []any) bool {
|
||||
names := map[string]bool{}
|
||||
for _, field := range actual {
|
||||
names[stringValue(field, "fieldName", "name")] = true
|
||||
}
|
||||
for _, raw := range expected {
|
||||
field, _ := raw.(map[string]any)
|
||||
if !names[stringValue(field, "fieldName", "name")] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runAITableCompositeCLI(t *testing.T, caller *upsertByKeyCaller, command string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(append([]string{"aitable", command}, args...))
|
||||
err := root.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
|
||||
t.Run("one table with legal empty fields and views", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"baseId":"base","tables":[{"tableId":"t1","name":"任务"}]}}`},
|
||||
{text: `{"data":{"tables":[{"tableId":"t1","name":"任务"}]}}`},
|
||||
{text: `{"data":{"fields":[]}}`},
|
||||
{text: `{"data":{"views":[]}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`"status": "success"`, `"tableCount": 1`, `"fields": []`, `"views": []`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("snapshot output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty tables is explicit success", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
|
||||
if err != nil || !strings.Contains(out, `"tableCount": 0`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("empty snapshot = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing tables contract fails", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","data":{}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("missing tables = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bootstrapFields(count int) []any {
|
||||
fields := make([]any, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
fields = append(fields, map[string]any{"fieldName": fmt.Sprintf("F%02d", index), "type": "text"})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func marshalBootstrapTables(t *testing.T, fields []any) string {
|
||||
t.Helper()
|
||||
if fields == nil {
|
||||
fields = []any{}
|
||||
}
|
||||
raw, err := json.Marshal([]any{map[string]any{"name": "任务", "fields": fields}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func fieldReadBackJSON(t *testing.T, fields []any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{"fields": fields})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testing.T) {
|
||||
fields := bootstrapFields(16)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"baseId":"b-new"}}`},
|
||||
{text: `{"baseId":"b-new","tables":[]}`},
|
||||
{text: `{"data":{"tableId":"t-new"}}`},
|
||||
{text: `{"createdFields":[{"fieldId":"f16"}]}`},
|
||||
{text: `{"tables":[{"tableId":"t-new","name":"任务"}]}`},
|
||||
{text: fieldReadBackJSON(t, fields)},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields), "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`"baseId": "b-new"`, `"tableId": "t-new"`, `"status": "verified"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bootstrap output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 6 || caller.calls[0].tool != "create_base" || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" {
|
||||
t.Fatalf("bootstrap calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["baseName"] != "项目" {
|
||||
t.Fatalf("create_base args = %#v", caller.calls[0].args)
|
||||
}
|
||||
createBaseCalls := 0
|
||||
for _, call := range caller.calls {
|
||||
if call.tool == "create_base" {
|
||||
createBaseCalls++
|
||||
}
|
||||
}
|
||||
if createBaseCalls != 1 {
|
||||
t.Fatalf("create_base calls = %d, want exactly 1; calls = %#v", createBaseCalls, caller.calls)
|
||||
}
|
||||
if got := len(caller.calls[2].args["fields"].([]any)); got != 15 {
|
||||
t.Fatalf("create_table fields = %d, want 15", got)
|
||||
}
|
||||
if got := len(caller.calls[3].args["fields"].([]any)); got != 1 {
|
||||
t.Fatalf("create_fields fields = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapRequiresConfirmationBeforeMCP(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil))
|
||||
if out != "" {
|
||||
t.Fatalf("unconfirmed bootstrap output = %q, want empty", out)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed bootstrap error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed bootstrap reached MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) {
|
||||
t.Run("empty create response is unknown and not retry-safe", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("unknown base create = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
|
||||
t.Fatalf("unknown base error = %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run validates and makes no calls", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("bootstrap dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapInputValidation(t *testing.T) {
|
||||
tooMany := make([]any, 101)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = map[string]any{"name": fmt.Sprintf("T%d", index)}
|
||||
}
|
||||
tooManyJSON, err := json.Marshal(tooMany)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"invalid JSON": `{`,
|
||||
"not an array": `{}`,
|
||||
"empty array": `[]`,
|
||||
"too many tables": string(tooManyJSON),
|
||||
"non-object table": `[1]`,
|
||||
"empty name": `[{"name":" "}]`,
|
||||
"duplicate name": `[{"name":"T"},{"name":"T"}]`,
|
||||
"fields not array": `[{"name":"T","fields":{}}]`,
|
||||
"field not object": `[{"name":"T","fields":[1]}]`,
|
||||
"field missing type": `[{"name":"T","fields":[{"fieldName":"F"}]}]`,
|
||||
}
|
||||
for name, raw := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if tables, err := parseBootstrapTables(raw); err == nil || tables != nil {
|
||||
t.Fatalf("parseBootstrapTables(%s) = %#v, %v", raw, tables, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) {
|
||||
base := `{"baseId":"base","tables":[{"tableId":"t1","name":"T"}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "get base error", steps: []upsertByKeyStep{{err: errors.New("get base failed")}}},
|
||||
{name: "get base wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"other","tables":[]}`}}},
|
||||
{name: "table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[{"name":"T"}]}`}}},
|
||||
{name: "get tables error", steps: []upsertByKeyStep{{text: base}, {err: errors.New("get tables failed")}}},
|
||||
{name: "get tables wrong id", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"other"}]}`}}},
|
||||
{name: "get fields error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {err: errors.New("get fields failed")}}},
|
||||
{name: "get fields missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{}`}}},
|
||||
{name: "get views error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {err: errors.New("get views failed")}}},
|
||||
{name: "get views missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {text: `{}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-schema-snapshot", "--base-id", "base")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("snapshot failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
|
||||
tables := marshalBootstrapTables(t, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
extra []string
|
||||
withField bool
|
||||
}{
|
||||
{name: "missing base id", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "base verification error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {err: errors.New("verify base failed")}}},
|
||||
{name: "base verification wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"other"}`}}},
|
||||
{name: "create table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {err: errors.New("create table failed")}}},
|
||||
{name: "create table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{}`}}},
|
||||
{name: "verify table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {err: errors.New("verify table failed")}}},
|
||||
{name: "verify table wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[]}`}}},
|
||||
{name: "verify fields error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {err: errors.New("verify fields failed")}}},
|
||||
{name: "verify fields mismatch", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{"fields":[]}`}}, extra: []string{"--folder-id", "folder", "--template-id", "template"}, withField: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputTables := tables
|
||||
if tc.withField {
|
||||
inputTables = marshalBootstrapTables(t, bootstrapFields(1))
|
||||
}
|
||||
args := []string{"--name", "Project", "--tables", inputTables}
|
||||
args = append(args, tc.extra...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("bootstrap failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.T) {
|
||||
fields := bootstrapFields(16)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"baseId":"b"}`},
|
||||
{text: `{"baseId":"b"}`},
|
||||
{text: `{"tableId":"t"}`},
|
||||
{err: errors.New("create fields reply failed")},
|
||||
{text: `{"tables":[{"tableId":"t"}]}`},
|
||||
{text: fieldReadBackJSON(t, fields)},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap",
|
||||
"--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") {
|
||||
t.Fatalf("field recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
if caller.calls[0].args["folderId"] != "folder" || caller.calls[0].args["templateId"] != "template" {
|
||||
t.Fatalf("create base args = %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCompositeShapeHelpers(t *testing.T) {
|
||||
if _, ok := findNamedObjectList(map[string]any{"tables": "bad"}, "tables"); ok {
|
||||
t.Fatal("non-array named child must fail")
|
||||
}
|
||||
if _, ok := findNamedObjectList(map[string]any{"tables": []any{"bad"}}, "tables"); ok {
|
||||
t.Fatal("non-object list item must fail")
|
||||
}
|
||||
if containsAllFieldNames([]map[string]any{{"fieldName": "A"}}, []any{map[string]any{"fieldName": "B"}}) {
|
||||
t.Fatal("missing field name must fail")
|
||||
}
|
||||
if got := findStringByKeys(map[string]any{"items": []any{map[string]any{"nested": " value "}}}, "nested"); got != "value" {
|
||||
t.Fatalf("nested array string = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func aitableCompositeContract(command, description, useWhen, avoidWhen, example string) corecmd.ContractDecl {
|
||||
name := strings.TrimPrefix(command, "+")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
cliPath := "aitable " + command
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_" + name,
|
||||
CanonicalPath: "aitable.shortcut_" + name,
|
||||
CLIPath: cliPath,
|
||||
PrimaryCLIPath: cliPath,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "DWS reviewed composite orchestration with explicit read-back success predicates, partial-effect reporting, and resumable checkpoints; no single MCP operation represents the full command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: []string{avoidWhen},
|
||||
Examples: []string{example},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const aitableCompositeContractVersion = "aitable.composite.v1"
|
||||
|
||||
type compositeResult struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Executed bool `json:"executed"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RequestedCount int `json:"requestedCount,omitempty"`
|
||||
CompletedCount int `json:"completedCount,omitempty"`
|
||||
FailedCount int `json:"failedCount,omitempty"`
|
||||
Resolved map[string]any `json:"resolved,omitempty"`
|
||||
Plan []compositeStep `json:"plan,omitempty"`
|
||||
CompletedSteps []compositeStep `json:"completedSteps,omitempty"`
|
||||
Verification map[string]any `json:"verification,omitempty"`
|
||||
Checkpoint map[string]any `json:"checkpoint,omitempty"`
|
||||
NextCommand string `json:"nextCommand,omitempty"`
|
||||
KnownEffects []map[string]any `json:"knownSideEffects,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type compositeStep struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func newCompositeResult(operation string) compositeResult {
|
||||
return compositeResult{
|
||||
ContractVersion: aitableCompositeContractVersion,
|
||||
Operation: operation,
|
||||
Status: "success",
|
||||
Executed: true,
|
||||
}
|
||||
}
|
||||
|
||||
func compositeError(result compositeResult, cause error, retryable bool) error {
|
||||
result.Retryable = retryable
|
||||
if result.Status == "success" {
|
||||
result.Status = "unknown"
|
||||
}
|
||||
return apperrors.NewAPI(fmt.Sprintf("AI Table composite %s ended with status %s", result.Operation, result.Status),
|
||||
apperrors.WithOperation("aitable."+result.Operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("composite_execution"),
|
||||
apperrors.WithExecutionStarted(result.Executed),
|
||||
apperrors.WithRetryable(retryable),
|
||||
apperrors.WithReason("aitable_composite_"+result.Status),
|
||||
apperrors.WithHint("inspect error.details.result before retrying; unknown means the remote effect could not be proven"),
|
||||
apperrors.WithDetails(map[string]any{"result": result}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITableListProjectionExplicitEmptyIsSuccessE2E(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
payload string
|
||||
}{
|
||||
{name: "base list", command: "+base-list", payload: `{"bases":[]}`},
|
||||
{name: "base search", command: "+base-search", args: []string{"--query", "none"}, payload: `{"data":{"bases":[]}}`},
|
||||
{name: "template search", command: "+template-search", payload: `{"data":{"templates":[]}}`},
|
||||
{name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[]}`},
|
||||
{name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"data":{"formViews":[]}}`},
|
||||
{name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"data":{"list":[]}}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, test.command, test.args...)
|
||||
if err != nil || !strings.Contains(out, `"count": 0`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("explicit empty list = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableListProjectionUnknownIsNotEmptySuccessE2E(t *testing.T) {
|
||||
commands := []struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
}{
|
||||
{name: "base list", command: "+base-list"},
|
||||
{name: "base search", command: "+base-search", args: []string{"--query", "none"}},
|
||||
{name: "template search", command: "+template-search"},
|
||||
{name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}},
|
||||
{name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}},
|
||||
{name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}},
|
||||
}
|
||||
payloads := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{name: "empty response", text: ""},
|
||||
{name: "missing collection", text: `{"success":true}`},
|
||||
{name: "wrong collection type", text: `{"data":{"list":{}}}`},
|
||||
}
|
||||
for _, command := range commands {
|
||||
for _, payload := range payloads {
|
||||
t.Run(command.name+"/"+payload.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: payload.text}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, command.command, command.args...)
|
||||
if err == nil || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("unknown list result was accepted = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableListProjectionMalformedItemIsNotSilentlyDroppedE2E(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
payload string
|
||||
}{
|
||||
{name: "base without id", command: "+base-list", payload: `{"bases":[{"baseName":"orphan"}]}`},
|
||||
{name: "template scalar", command: "+template-search", payload: `{"templates":["bad"]}`},
|
||||
{name: "view without id", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[{"viewName":"orphan"}]}`},
|
||||
{name: "form without id", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"forms":[{"viewName":"orphan"}]}`},
|
||||
{name: "workflow without id", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"workflows":[{"name":"orphan"}]}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, test.command, test.args...)
|
||||
if err == nil || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("malformed list item was accepted = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const (
|
||||
recordBatchSize = 100
|
||||
maxCompositeRecordRun = 10000
|
||||
)
|
||||
|
||||
func parseRecordObjects(raw string, requireRecordID bool) ([]map[string]any, error) {
|
||||
value, err := parseJSONAny("records", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, apperrors.NewValidation("--records 必须是非空 JSON 数组")
|
||||
}
|
||||
if len(list) > maxCompositeRecordRun {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--records 最多接受 %d 条,got %d", maxCompositeRecordRun, len(list)))
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
record, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 必须是 JSON 对象", index))
|
||||
}
|
||||
cells, ok := record["cells"].(map[string]any)
|
||||
if !ok || len(cells) == 0 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d].cells 必须是非空 JSON 对象", index))
|
||||
}
|
||||
if requireRecordID && recordID(record) == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 缺少 recordId", index))
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeRecordUpdateBatches(rt *shortcut.RuntimeContext) error {
|
||||
records, err := parseRecordObjects(rt.Str("records"), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return executeRecordBatches(rt, "record_update", "update_records", serverMain, records, verifyUpdateBatch)
|
||||
}
|
||||
|
||||
func executeRecordUpsertBatches(rt *shortcut.RuntimeContext) error {
|
||||
records, err := parseRecordObjects(rt.Str("records"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return executeRecordBatches(rt, "record_upsert", "record_upsert", serverHelper, records, verifyUpsertBatch)
|
||||
}
|
||||
|
||||
func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
ids, err := parseRecordIDs(rt.StrSlice("record-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
|
||||
result := newCompositeResult("record_delete")
|
||||
result.RequestedCount = len(ids)
|
||||
for offset := 0; offset < len(ids); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(ids))
|
||||
result.Plan = append(result.Plan, compositeStep{
|
||||
Index: len(result.Plan) + 1, Name: "delete record batch", Tool: "delete_records",
|
||||
Status: "planned", Offset: offset, Count: end - offset,
|
||||
})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(ids); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(ids))
|
||||
batch := ids[offset:end]
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": batch,
|
||||
})
|
||||
step := compositeStep{
|
||||
Index: len(result.CompletedSteps) + 1, Name: "delete record batch", Tool: "delete_records",
|
||||
Status: "completed", Offset: offset, Count: len(batch), Result: writeData,
|
||||
}
|
||||
if writeErr != nil {
|
||||
step.Status = "unknown"
|
||||
step.Error = writeErr.Error()
|
||||
}
|
||||
remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch)
|
||||
if verifyErr == nil && len(remaining) > 0 {
|
||||
verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ","))
|
||||
}
|
||||
if verifyErr != nil {
|
||||
if step.Error == "" {
|
||||
step.Error = verifyErr.Error()
|
||||
}
|
||||
step.Status = "unknown"
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = offset
|
||||
result.FailedCount = len(ids) - offset
|
||||
result.Status = "unknown"
|
||||
if offset > 0 {
|
||||
result.Status = "partial_success"
|
||||
}
|
||||
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset}
|
||||
result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize}
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
|
||||
}
|
||||
return compositeError(result, verifyErr, true)
|
||||
}
|
||||
if writeErr != nil {
|
||||
step.Status = "recovered"
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but deletion was proven by read-back", offset))
|
||||
}
|
||||
step.Result = map[string]any{"status": "verified_absent", "recordIds": batch}
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = end
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
|
||||
}
|
||||
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
|
||||
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func parseRecordIDs(values []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, apperrors.NewValidation("--record-ids 必须至少包含一个非空 recordId")
|
||||
}
|
||||
if len(ids) > maxCompositeRecordRun {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--record-ids 最多接受 %d 个唯一 ID,got %d", maxCompositeRecordRun, len(ids)))
|
||||
}
|
||||
// Stable ordering makes checkpoints and retries deterministic even when the
|
||||
// flag parser or a caller supplied IDs through more than one occurrence.
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type recordBatchVerifier func(*shortcut.RuntimeContext, string, string, []map[string]any, map[string]any) (map[string]any, error)
|
||||
|
||||
func executeRecordBatches(
|
||||
rt *shortcut.RuntimeContext,
|
||||
operation, tool, product string,
|
||||
records []map[string]any,
|
||||
verify recordBatchVerifier,
|
||||
) error {
|
||||
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
|
||||
result := newCompositeResult(operation)
|
||||
result.RequestedCount = len(records)
|
||||
for offset := 0; offset < len(records); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(records))
|
||||
result.Plan = append(result.Plan, compositeStep{
|
||||
Index: len(result.Plan) + 1, Name: "write record batch", Tool: tool,
|
||||
Status: "planned", Offset: offset, Count: end - offset,
|
||||
})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(records); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(records))
|
||||
batch := records[offset:end]
|
||||
wireRecords := make([]any, 0, len(batch))
|
||||
for _, record := range batch {
|
||||
wireRecords = append(wireRecords, record)
|
||||
}
|
||||
params := map[string]any{"baseId": baseID, "tableId": tableID, "records": wireRecords}
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(product, tool, params)
|
||||
step := compositeStep{
|
||||
Index: len(result.CompletedSteps) + 1, Name: "write record batch", Tool: tool,
|
||||
Status: "completed", Offset: offset, Count: len(batch), Result: writeData,
|
||||
}
|
||||
if writeErr != nil {
|
||||
step.Status = "unknown"
|
||||
step.Error = writeErr.Error()
|
||||
}
|
||||
verification, verifyErr := verify(rt, baseID, tableID, batch, writeData)
|
||||
if verifyErr != nil {
|
||||
step.Status = "unknown"
|
||||
if step.Error == "" {
|
||||
step.Error = verifyErr.Error()
|
||||
}
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = offset
|
||||
result.FailedCount = len(records) - offset
|
||||
result.Status = "unknown"
|
||||
if offset > 0 {
|
||||
result.Status = "partial_success"
|
||||
}
|
||||
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset}
|
||||
result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize}
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
|
||||
}
|
||||
retryable := true
|
||||
if tool == "record_upsert" {
|
||||
for _, record := range batch {
|
||||
if recordID(record) == "" {
|
||||
retryable = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return compositeError(result, verifyErr, retryable)
|
||||
}
|
||||
if writeErr != nil {
|
||||
step.Status = "recovered"
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but its final state was proven by read-back", offset))
|
||||
}
|
||||
step.Result = verification
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = end
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "offset": offset, "count": len(batch)})
|
||||
}
|
||||
result.Verification = map[string]any{"status": "verified", "verifiedCount": len(records)}
|
||||
result.Result = map[string]any{"processedCount": len(records), "batchCount": len(result.CompletedSteps)}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func verifyUpdateBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, _ map[string]any) (map[string]any, error) {
|
||||
ids := recordIDs(batch)
|
||||
actual, err := queryRecordsByIDs(rt, baseID, tableID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[string]map[string]any, len(actual))
|
||||
for _, record := range actual {
|
||||
byID[recordID(record)] = record
|
||||
}
|
||||
for _, expected := range batch {
|
||||
id := recordID(expected)
|
||||
got := byID[id]
|
||||
if got == nil {
|
||||
return nil, fmt.Errorf("read-back is missing updated record %s", id)
|
||||
}
|
||||
if err := verifyRecordCells(got, expected["cells"].(map[string]any)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"status": "verified", "recordIds": ids}, nil
|
||||
}
|
||||
|
||||
func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, writeData map[string]any) (map[string]any, error) {
|
||||
updates := make([]map[string]any, 0)
|
||||
creates := make([]map[string]any, 0)
|
||||
for _, record := range batch {
|
||||
if recordID(record) == "" {
|
||||
creates = append(creates, record)
|
||||
} else {
|
||||
updates = append(updates, record)
|
||||
}
|
||||
}
|
||||
verifiedIDs := make([]string, 0, len(batch))
|
||||
if len(updates) > 0 {
|
||||
verified, err := verifyUpdateBatch(rt, baseID, tableID, updates, writeData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedIDs = append(verifiedIDs, stringSlice(verified["recordIds"])...)
|
||||
}
|
||||
if len(creates) > 0 {
|
||||
createdIDs := createdRecordIDs(writeData)
|
||||
if len(createdIDs) != len(creates) {
|
||||
return nil, fmt.Errorf("record_upsert returned %d created record IDs for %d creates", len(createdIDs), len(creates))
|
||||
}
|
||||
actual, err := queryRecordsByIDs(rt, baseID, tableID, createdIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := matchCreatedCells(creates, actual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedIDs = append(verifiedIDs, createdIDs...)
|
||||
}
|
||||
return map[string]any{"status": "verified", "recordIds": verifiedIDs}, nil
|
||||
}
|
||||
|
||||
func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
|
||||
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, found := findRecords(data)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("query_records read-back is missing records")
|
||||
}
|
||||
if responseHasMore(data) {
|
||||
return nil, fmt.Errorf("query_records read-back is incomplete")
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func recordIDs(records []map[string]any) []string {
|
||||
out := make([]string, 0, len(records))
|
||||
seen := map[string]bool{}
|
||||
for _, record := range records {
|
||||
id := recordID(record)
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func createdRecordIDs(data map[string]any) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0)
|
||||
var walk func(any, bool)
|
||||
walk = func(value any, createdContext bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if createdContext {
|
||||
if id := recordID(typed); id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
for key, child := range typed {
|
||||
lower := strings.ToLower(key)
|
||||
nextContext := createdContext || strings.Contains(lower, "created")
|
||||
walk(child, nextContext)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if createdContext {
|
||||
if id, ok := child.(string); ok {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(child, createdContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(data, false)
|
||||
return out
|
||||
}
|
||||
|
||||
func matchCreatedCells(expected, actual []map[string]any) error {
|
||||
if len(actual) != len(expected) {
|
||||
return fmt.Errorf("read-back returned %d created records, want %d", len(actual), len(expected))
|
||||
}
|
||||
used := make([]bool, len(actual))
|
||||
for _, wantRecord := range expected {
|
||||
wantCells := wantRecord["cells"].(map[string]any)
|
||||
matched := false
|
||||
for index, gotRecord := range actual {
|
||||
if used[index] || verifyRecordCells(gotRecord, wantCells) != nil {
|
||||
continue
|
||||
}
|
||||
used[index] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("no created read-back record matches cells %#v", wantCells)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringSlice(value any) []string {
|
||||
values, _ := value.([]string)
|
||||
return values
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var RecordBulkPatch = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-bulk-patch",
|
||||
Product: serverMain,
|
||||
Description: "完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证",
|
||||
Intent: "当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: aitableCompositeContract(
|
||||
"+record-bulk-patch",
|
||||
"完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证",
|
||||
"当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。",
|
||||
"单条已知 recordId 用 record update;每条记录写不同值用 record update/upsert;未明确范围时不要执行",
|
||||
`dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "patch", Type: shortcut.FlagString, Desc: "要合并到每条记录的非空 cells JSON 对象", Required: true},
|
||||
{Name: "filters", Type: shortcut.FlagString, Desc: "query_records filters JSON(选择条件之一)"},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "全文关键词(选择条件之一)"},
|
||||
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "明确的 recordId 列表(选择条件之一)"},
|
||||
{Name: "view-id", Type: shortcut.FlagString, Desc: "可选视图上下文"},
|
||||
{Name: "all", Type: shortcut.FlagBool, Desc: "明确允许匹配整张表"},
|
||||
{Name: "max-matches", Type: shortcut.FlagInt, Default: "1000", Desc: "写入前允许匹配的最大记录数,1-10000"},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeRecordBulkPatch(rt)
|
||||
},
|
||||
}
|
||||
|
||||
func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error {
|
||||
patch, err := parseJSONObject("patch", rt.Str("patch"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return apperrors.NewValidation("--patch 必须是非空 JSON 对象")
|
||||
}
|
||||
params := map[string]any{"baseId": rt.Str("base-id"), "tableId": rt.Str("table-id")}
|
||||
selectorCount := 0
|
||||
if rt.Changed("filters") {
|
||||
filters, err := parseBulkPatchFilters(rt.Str("filters"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filters"] = filters
|
||||
selectorCount++
|
||||
}
|
||||
if rt.Changed("query") {
|
||||
query := strings.TrimSpace(rt.Str("query"))
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query 必须包含非空白关键词")
|
||||
}
|
||||
params["keyword"] = query
|
||||
selectorCount++
|
||||
}
|
||||
if rt.Changed("record-ids") {
|
||||
ids, err := parseRecordIDs(rt.StrSlice("record-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["recordIds"] = ids
|
||||
selectorCount++
|
||||
}
|
||||
if rt.Changed("all") {
|
||||
if !rt.Bool("all") {
|
||||
return apperrors.NewValidation("--all=false 不构成全表授权")
|
||||
}
|
||||
selectorCount++
|
||||
}
|
||||
if selectorCount == 0 {
|
||||
return apperrors.NewValidation("必须显式提供 --filters、--query、--record-ids 或 --all")
|
||||
}
|
||||
maxMatches := rt.Int("max-matches")
|
||||
if maxMatches < 1 || maxMatches > maxCompositeRecordRun {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--max-matches 必须在 1..%d", maxCompositeRecordRun))
|
||||
}
|
||||
if rt.Changed("view-id") {
|
||||
params["viewId"] = rt.Str("view-id")
|
||||
}
|
||||
records, err := queryAllRecords(rt, params, maxMatches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
id := recordID(record)
|
||||
if id == "" {
|
||||
return fmt.Errorf("query_records returned a matched record without recordId")
|
||||
}
|
||||
updates = append(updates, map[string]any{"recordId": id, "cells": cloneAnyMap(patch)})
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
result := newCompositeResult("record_bulk_patch")
|
||||
result.RequestedCount = 0
|
||||
result.Verification = map[string]any{"status": "verified_no_matches"}
|
||||
result.Result = map[string]any{"matchedCount": 0, "updatedCount": 0}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
return executeRecordBatches(rt, "record_bulk_patch", "update_records", serverMain, updates, verifyUpdateBatch)
|
||||
}
|
||||
|
||||
func parseBulkPatchFilters(raw string) (map[string]any, error) {
|
||||
filters, err := parseJSONObject("filters", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bulkPatchFilterConstrictsScope(filters, true) {
|
||||
return nil, apperrors.NewValidation("--filters 必须是包含非空筛选条件的 JSON 对象,根 operator 必须为 and/or")
|
||||
}
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func bulkPatchFilterConstrictsScope(value any, root bool) bool {
|
||||
filter, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
operator, ok := filter["operator"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
operator = strings.ToLower(strings.TrimSpace(operator))
|
||||
if operator == "" {
|
||||
return false
|
||||
}
|
||||
operands, ok := filter["operands"].([]any)
|
||||
if !ok || len(operands) == 0 {
|
||||
return false
|
||||
}
|
||||
if root && operator != "and" && operator != "or" {
|
||||
return false
|
||||
}
|
||||
if operator == "and" || operator == "or" {
|
||||
for _, operand := range operands {
|
||||
if !bulkPatchFilterConstrictsScope(operand, false) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch operator {
|
||||
case "eq", "ne", "gt", "lt", "gte", "lte", "contain", "exclusive",
|
||||
"exist", "un_exist", "any_of", "all_of", "none_of", "date_eq",
|
||||
"before", "after", "not_before", "not_after":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
fieldID, ok := operands[0].(string)
|
||||
if !ok || strings.TrimSpace(fieldID) == "" {
|
||||
return false
|
||||
}
|
||||
return (operator == "exist" || operator == "un_exist") || len(operands) >= 2
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
|
||||
tooMany := make([]any, maxCompositeRecordRun+1)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = map[string]any{"cells": map[string]any{"f": index}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
requireID bool
|
||||
}{
|
||||
{name: "invalid JSON", raw: `{`},
|
||||
{name: "not array", raw: `{}`},
|
||||
{name: "empty array", raw: `[]`},
|
||||
{name: "too many", raw: mustJSONText(t, tooMany)},
|
||||
{name: "non-object", raw: `[1]`},
|
||||
{name: "missing cells", raw: `[{"recordId":"r"}]`},
|
||||
{name: "empty cells", raw: `[{"recordId":"r","cells":{}}]`},
|
||||
{name: "missing record id", raw: `[{"cells":{"f":1}}]`, requireID: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if records, err := parseRecordObjects(tc.raw, tc.requireID); err == nil || records != nil {
|
||||
t.Fatalf("parseRecordObjects = %#v, %v", records, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if ids, err := parseRecordIDs([]string{" ", "b", "a", "a"}); err != nil || strings.Join(ids, ",") != "a,b" {
|
||||
t.Fatalf("parseRecordIDs = %#v, %v", ids, err)
|
||||
}
|
||||
tooManyIDs := make([]string, maxCompositeRecordRun+1)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = strconv.Itoa(index)
|
||||
}
|
||||
if ids, err := parseRecordIDs(tooManyIDs); err == nil || ids != nil {
|
||||
t.Fatalf("too many IDs = %#v, %v", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchEntryValidationE2E(t *testing.T) {
|
||||
for _, command := range []string{"+record-update", "+record-upsert"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, command,
|
||||
"--base-id", "base", "--table-id", "table", "--records", `{`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid records = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-delete",
|
||||
"--base-id", "base", "--table-id", "table", "--record-ids", ` `, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid record IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"}, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("delete dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
|
||||
caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("write failed")}, {err: errors.New("verify failed")}}}
|
||||
out, err = runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete dual failure = output:%q err:%v", out, err)
|
||||
}
|
||||
|
||||
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, []string{"", " "})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete parsed empty IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
|
||||
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, recordIDFixtures(maxCompositeRecordRun+1))
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete excessive IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
|
||||
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
|
||||
|
||||
t.Run("read-back query error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"updatedCount":1}`}, {err: errors.New("read failed")}}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("query error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back cells mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"f":"old"}}]}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("cell mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert create read error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"new"}]}`},
|
||||
{err: errors.New("created read failed")},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("create read error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert create cells mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"new"}]}`},
|
||||
{text: `{"records":[{"recordId":"new","cells":{"f":"old"}}]}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("create mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert update read error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[],"updatedCount":1}`},
|
||||
{err: errors.New("updated read failed")},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("update read error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete update read", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"f":"new"}}],"hasMore":true}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("incomplete read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchPureHelpers(t *testing.T) {
|
||||
if err := matchCreatedCells([]map[string]any{{"cells": map[string]any{"f": 1}}}, nil); err == nil {
|
||||
t.Fatal("created result count mismatch must fail")
|
||||
}
|
||||
if err := matchCreatedCells(
|
||||
[]map[string]any{{"cells": map[string]any{"f": 1}}},
|
||||
[]map[string]any{{"recordId": "r", "cells": map[string]any{"f": 2}}},
|
||||
); err == nil {
|
||||
t.Fatal("unmatched created cells must fail")
|
||||
}
|
||||
ids := createdRecordIDs(map[string]any{"createdIds": []any{" r1 ", "r1", map[string]any{"record_id": "r2"}}})
|
||||
if strings.Join(ids, ",") != "r1,r2" {
|
||||
t.Fatalf("created IDs = %#v", ids)
|
||||
}
|
||||
if got := stringSlice("bad"); got != nil {
|
||||
t.Fatalf("stringSlice = %#v", got)
|
||||
}
|
||||
if minInt(1, 2) != 1 || minInt(2, 1) != 1 {
|
||||
t.Fatal("minInt branch mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "invalid patch", args: []string{"--all", "--patch", `{`}},
|
||||
{name: "empty patch", args: []string{"--all", "--patch", `{}`}},
|
||||
{name: "false all", args: []string{"--all=false", "--patch", `{"f":1}`}},
|
||||
{name: "invalid max", args: []string{"--all", "--patch", `{"f":1}`, "--max-matches", "0"}},
|
||||
{name: "invalid filters", args: []string{"--filters", `{`, "--patch", `{"f":1}`}},
|
||||
{name: "null filters", args: []string{"--filters", `null`, "--patch", `{"f":1}`}},
|
||||
{name: "empty filters object", args: []string{"--filters", `{}`, "--patch", `{"f":1}`}},
|
||||
{name: "empty filters operands", args: []string{"--filters", `{"operator":"and","operands":[]}`, "--patch", `{"f":1}`}},
|
||||
{name: "missing filters operator", args: []string{"--filters", `{"operands":[{"operator":"eq","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
|
||||
{name: "blank child operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":" ","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
|
||||
{name: "null filters operands", args: []string{"--filters", `{"operator":"and","operands":null}`, "--patch", `{"f":1}`}},
|
||||
{name: "non condition operand", args: []string{"--filters", `{"operator":"and","operands":[null]}`, "--patch", `{"f":1}`}},
|
||||
{name: "root comparison", args: []string{"--filters", `{"operator":"eq","operands":["fld","x"]}`, "--patch", `{"f":1}`}},
|
||||
{name: "blank filter field", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":[" ","x"]}]}`, "--patch", `{"f":1}`}},
|
||||
{name: "unknown filter operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"unknown","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
|
||||
{name: "missing comparison value", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":["fld"]}]}`, "--patch", `{"f":1}`}},
|
||||
{name: "blank query", args: []string{"--query", ` `, "--patch", `{"f":1}`}},
|
||||
{name: "empty record IDs", args: []string{"--record-ids", ` `, "--patch", `{"f":1}`}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table"}
|
||||
args = append(args, tc.args...)
|
||||
args = append(args, "--yes")
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", args...)
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("validation = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"eq","operands":["fld","x"]}]}]}`, "--query", "text",
|
||||
"--record-ids", "r2,r1", "--view-id", "view", "--patch", `{"f":1}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"matchedCount": 0`) {
|
||||
t.Fatalf("selectors = output:%q err:%v", out, err)
|
||||
}
|
||||
for _, key := range []string{"filters", "keyword", "recordIds", "viewId"} {
|
||||
if _, ok := caller.calls[0].args[key]; !ok {
|
||||
t.Fatalf("query args missing %s: %#v", key, caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
caller = &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err = runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"f":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("empty dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBulkPatchQueryFailuresE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "query error", steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
|
||||
{name: "missing records", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "more without cursor", steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
|
||||
{name: "cursor cycle", steps: []upsertByKeyStep{
|
||||
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
|
||||
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
|
||||
}},
|
||||
{name: "matched record missing id", steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"f":1}}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("query failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryPageSafetyBoundE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return fmt.Sprintf(`{"records":[],"hasMore":true,"nextCursor":"cursor-%d"}`, index), nil
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 10000 {
|
||||
t.Fatalf("page safety = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertInputAndQueryFailuresE2E(t *testing.T) {
|
||||
baseArgs := []string{"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--cells", `{"value":1}`, "--yes"}
|
||||
cases := []struct {
|
||||
name string
|
||||
key []string
|
||||
cells string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "empty string key", key: []string{"--key-value", " "}},
|
||||
{name: "invalid JSON key", key: []string{"--key-value-json", `{`}},
|
||||
{name: "null JSON key", key: []string{"--key-value-json", `null`}},
|
||||
{name: "object JSON key", key: []string{"--key-value-json", `{}`}},
|
||||
{name: "invalid cells", key: []string{"--key-value", "v"}, cells: `{`},
|
||||
{name: "conflicting key", key: []string{"--key-value", "v"}, cells: `{"key":"other"}`},
|
||||
{name: "query error", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
|
||||
{name: "missing records", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "incomplete unique query", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
|
||||
{name: "record missing id", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"key":"v"}}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, baseArgs...)
|
||||
args = append(args, tc.key...)
|
||||
if tc.cells != "" {
|
||||
for index := range args {
|
||||
if args[index] == "--cells" {
|
||||
args[index+1] = tc.cells
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-upsert-by-key", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("upsert failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
|
||||
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", `true`,
|
||||
"--cells", `{"value":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"keyValue": true`) {
|
||||
t.Fatalf("JSON scalar = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertRejectsTrailingJSONBeforeMCPE2E(t *testing.T) {
|
||||
for name, value := range map[string]string{
|
||||
"trailing garbage": `1 garbage`,
|
||||
"second JSON value": `1 2`,
|
||||
"second string value": `true "extra"`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
|
||||
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", value,
|
||||
"--cells", `{"value":1}`, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("trailing JSON = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
|
||||
if records, ok := findRecords(nil); ok || records != nil {
|
||||
t.Fatalf("nil records = %#v, %v", records, ok)
|
||||
}
|
||||
if _, ok := findRecords(map[string]any{"records": "bad"}); ok {
|
||||
t.Fatal("non-array records must fail")
|
||||
}
|
||||
if _, ok := findRecords(map[string]any{"records": []any{"bad"}}); ok {
|
||||
t.Fatal("non-object record must fail")
|
||||
}
|
||||
if records, ok := findRecords(map[string]any{"result": map[string]any{"records": []any{map[string]any{"id": "r"}}}}); !ok || recordID(records[0]) != "r" {
|
||||
t.Fatalf("nested records = %#v, %v", records, ok)
|
||||
}
|
||||
if responseHasMore(nil) || !responseHasMore(map[string]any{"cursor": "next"}) || !responseHasMore(map[string]any{"pagination": map[string]any{"hasMore": true}}) {
|
||||
t.Fatal("responseHasMore shape mismatch")
|
||||
}
|
||||
if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil {
|
||||
t.Fatal("missing cells must fail")
|
||||
}
|
||||
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" ||
|
||||
responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" {
|
||||
t.Fatal("responseCursor shape mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
cursor := ""
|
||||
seen := map[string]bool{}
|
||||
for page := 0; ; page++ {
|
||||
request := cloneAnyMap(params)
|
||||
request["limit"] = recordBatchSize
|
||||
if cursor != "" {
|
||||
request["cursor"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "query_records", request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, found := findRecords(data)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("query_records page %d is missing the records collection", page+1)
|
||||
}
|
||||
all = append(all, records...)
|
||||
if maxRecords > 0 && len(all) > maxRecords {
|
||||
return nil, fmt.Errorf("query matched more than the allowed %d records", maxRecords)
|
||||
}
|
||||
if !responseHasMore(data) {
|
||||
return all, nil
|
||||
}
|
||||
next := responseCursor(data)
|
||||
if next == "" {
|
||||
return nil, fmt.Errorf("query_records page %d reports more data but no next cursor", page+1)
|
||||
}
|
||||
if seen[next] || next == cursor {
|
||||
return nil, fmt.Errorf("query_records cursor cycle detected at %q", next)
|
||||
}
|
||||
seen[next] = true
|
||||
cursor = next
|
||||
if page >= 9999 {
|
||||
return nil, fmt.Errorf("query_records exceeded the 10000-page safety bound")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responseCursor(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"nextCursor", "next_cursor", "cursor"} {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"data", "result", "pagination", "page"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
if cursor := responseCursor(nested); cursor != "" {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneAnyMap(input map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// RecordUpsertByKey creates or updates exactly one record selected by a unique
|
||||
// field value. It never writes when the key is ambiguous and always verifies
|
||||
// the final record through a read-back query.
|
||||
var RecordUpsertByKey = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-upsert-by-key",
|
||||
Product: serverMain,
|
||||
Description: "按唯一字段值有则更新、无则创建记录,并读回验证",
|
||||
Intent: "已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_record_upsert_by_key",
|
||||
CanonicalPath: "aitable.shortcut_record_upsert_by_key",
|
||||
CLIPath: "aitable +record-upsert-by-key",
|
||||
PrimaryCLIPath: "aitable +record-upsert-by-key",
|
||||
},
|
||||
Description: "按唯一字段值有则更新、无则创建记录,并读回验证。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "The command composes query_records with create_records/update_records and a final read-back; no single RPC owns its uniqueness and verification contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按唯一字段值有则更新、无则创建记录,并读回验证",
|
||||
UseWhen: []string{"已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。"},
|
||||
AvoidWhen: []string{"已经知道 recordId 时用 +record-update;一次处理多条不同键值时使用批量导入或分批调用"},
|
||||
Examples: []string{
|
||||
"dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{\"fldStatus\":\"进行中\"}'",
|
||||
},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{
|
||||
Index: recordUpsertExampleIndex(),
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "dry-run must query the live table to prove whether the unique key matches zero or one record; the isolated Agent example runner has no remote AITable fixture",
|
||||
Reviewed: true,
|
||||
}},
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: true},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "key-field-id", Type: shortcut.FlagString, Desc: "具有唯一语义的字段 ID", Required: true},
|
||||
{Name: "key-value", Type: shortcut.FlagString, Desc: "字符串键值;与 --key-value-json 二选一"},
|
||||
{Name: "key-value-json", Type: shortcut.FlagString, Desc: "JSON 类型键值;与 --key-value 二选一"},
|
||||
{Name: "cells", Type: shortcut.FlagString, Desc: "要写入的 cells JSON 对象", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"key-value", "key-value-json"}, Description: "必须且只能提供一种键值表示"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{"fldStatus":"进行中"}'`,
|
||||
},
|
||||
Execute: executeRecordUpsertByKey,
|
||||
}
|
||||
|
||||
func recordUpsertExampleIndex() *int {
|
||||
index := 0
|
||||
return &index
|
||||
}
|
||||
|
||||
func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error {
|
||||
keyValue, err := recordKeyValue(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cells, err := parseJSONObject("cells", rt.Str("cells"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyFieldID := rt.Str("key-field-id")
|
||||
if existing, present := cells[keyFieldID]; present && !reflect.DeepEqual(existing, keyValue) {
|
||||
return apperrors.NewValidation("--cells 中的键字段值与 --key-value/--key-value-json 冲突",
|
||||
apperrors.WithReason("key_value_conflict"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
cells[keyFieldID] = keyValue
|
||||
|
||||
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
|
||||
preflight, err := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action, tool := "create", "create_records"
|
||||
writeRecord := map[string]any{"cells": cells}
|
||||
if preflight != nil {
|
||||
action, tool = "update", "update_records"
|
||||
writeRecord["recordId"] = recordID(preflight)
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID,
|
||||
"records": []any{writeRecord},
|
||||
}
|
||||
result := newCompositeResult("record_upsert_by_key")
|
||||
result.Resolved = map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "keyFieldId": keyFieldID,
|
||||
"keyValue": keyValue, "matchedCount": boolCount(preflight != nil), "action": action,
|
||||
}
|
||||
result.RequestedCount = 1
|
||||
result.Plan = []compositeStep{{Index: 1, Name: action + " record", Tool: tool, Status: "planned", Count: 1, Arguments: params}}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
|
||||
writeStep := compositeStep{Index: 1, Name: action + " record", Tool: tool, Status: "completed", Count: 1, Result: writeData}
|
||||
if writeErr != nil {
|
||||
writeStep.Status = "unknown"
|
||||
writeStep.Error = writeErr.Error()
|
||||
}
|
||||
result.CompletedSteps = append(result.CompletedSteps, writeStep)
|
||||
|
||||
verified, verifyErr := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue)
|
||||
if verifyErr == nil && verified != nil {
|
||||
verifyErr = verifyRecordCells(verified, cells)
|
||||
}
|
||||
if verifyErr != nil || verified == nil {
|
||||
result.Status = "unknown"
|
||||
result.FailedCount = 1
|
||||
// An update targets a known record ID and is safe to retry. A create whose
|
||||
// effect could not be read back must be resolved by the unique-key query
|
||||
// first; an immediate blind retry could duplicate the row under eventual
|
||||
// consistency.
|
||||
retryable := action == "update"
|
||||
result.Retryable = retryable
|
||||
if verifyErr == nil {
|
||||
verifyErr = fmt.Errorf("read-back found no record for the unique key")
|
||||
}
|
||||
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error()}
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "write call also returned an error: "+writeErr.Error())
|
||||
}
|
||||
result.Checkpoint = map[string]any{
|
||||
"nextStep": "query the unique key again and verify its cells before retrying",
|
||||
"keyFieldId": keyFieldID,
|
||||
"keyValue": keyValue,
|
||||
}
|
||||
return compositeError(result, verifyErr, retryable)
|
||||
}
|
||||
|
||||
verifiedID := recordID(verified)
|
||||
result.CompletedCount = 1
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"action": action, "recordId": verifiedID, "keyFieldId": keyFieldID, "keyValue": keyValue})
|
||||
result.Verification = map[string]any{
|
||||
"status": "verified", "recordId": verifiedID,
|
||||
"checkedFields": sortedMapKeys(cells),
|
||||
}
|
||||
result.Result = map[string]any{"action": action, "recordId": verifiedID, "record": verified}
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, "write response was an error, but the requested final state was proven by read-back")
|
||||
result.CompletedSteps[0].Status = "recovered"
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) {
|
||||
if rt.Changed("key-value") {
|
||||
// The command framework rejects an explicitly empty string before Execute.
|
||||
return rt.Str("key-value"), nil
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(rt.Str("key-value-json")))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error())
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return nil, apperrors.NewValidation("--key-value-json 必须只包含一个 JSON 标量")
|
||||
}
|
||||
if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice {
|
||||
return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func queryUniqueRecordByKey(rt *shortcut.RuntimeContext, baseID, tableID, fieldID string, value any) (map[string]any, error) {
|
||||
filters := map[string]any{
|
||||
"operator": "and",
|
||||
"operands": []any{map[string]any{
|
||||
"operator": "eq", "operands": []any{fieldID, value},
|
||||
}},
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "filters": filters, "limit": 2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, found := findRecords(data)
|
||||
if !found {
|
||||
return nil, apperrors.NewAPI("query_records response is missing the records collection",
|
||||
apperrors.WithOperation("aitable/query_records"),
|
||||
apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if responseHasMore(data) {
|
||||
return nil, apperrors.NewAPI("unique-key query is incomplete and cannot prove uniqueness",
|
||||
apperrors.WithOperation("aitable/query_records"),
|
||||
apperrors.WithReason("target_incomplete"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithDetails(map[string]any{"records": records}),
|
||||
)
|
||||
}
|
||||
switch len(records) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
if recordID(records[0]) == "" {
|
||||
return nil, apperrors.NewAPI("query_records returned a record without recordId",
|
||||
apperrors.WithOperation("aitable/query_records"),
|
||||
apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
return records[0], nil
|
||||
default:
|
||||
return nil, apperrors.NewValidation("唯一键匹配到多条记录,已在写入前停止",
|
||||
apperrors.WithReason("target_ambiguous"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithDetails(map[string]any{"records": records}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func findRecords(data map[string]any) ([]map[string]any, bool) {
|
||||
if data == nil {
|
||||
return nil, false
|
||||
}
|
||||
if raw, exists := data["records"]; exists {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
record, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
for _, key := range []string{"data", "result"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
if records, found := findRecords(nested); found {
|
||||
return records, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func responseHasMore(data map[string]any) bool {
|
||||
if data == nil {
|
||||
return false
|
||||
}
|
||||
if value, ok := data["hasMore"].(bool); ok && value {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"nextCursor", "cursor"} {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"data", "result", "pagination", "page"} {
|
||||
if nested, ok := data[key].(map[string]any); ok && responseHasMore(nested) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordID(record map[string]any) string {
|
||||
for _, key := range []string{"recordId", "record_id", "id"} {
|
||||
if value, ok := record[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func verifyRecordCells(record map[string]any, expected map[string]any) error {
|
||||
actual, ok := record["cells"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("read-back record %q is missing cells", recordID(record))
|
||||
}
|
||||
for fieldID, want := range expected {
|
||||
got, exists := actual[fieldID]
|
||||
if !exists || !reflect.DeepEqual(got, want) {
|
||||
return fmt.Errorf("read-back mismatch for field %s: got %#v, want %#v", fieldID, got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedMapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func boolCount(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type upsertByKeyStep struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
type upsertByKeyCaller struct {
|
||||
steps []upsertByKeyStep
|
||||
calls []aitableURLCall
|
||||
dryRun bool
|
||||
callFn func(index int, product, tool string, args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
func (c *upsertByKeyCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.call(product, tool, args)
|
||||
}
|
||||
|
||||
func (c *upsertByKeyCaller) CallReadTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.call(product, tool, args)
|
||||
}
|
||||
|
||||
func (c *upsertByKeyCaller) call(product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
cloned := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
cloned[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned})
|
||||
index := len(c.calls) - 1
|
||||
if c.callFn != nil {
|
||||
text, err := c.callFn(index, product, tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return urlResult(text), nil
|
||||
}
|
||||
if index >= len(c.steps) {
|
||||
return nil, errors.New("unexpected upsert-by-key call")
|
||||
}
|
||||
step := c.steps[index]
|
||||
if step.err != nil {
|
||||
return nil, step.err
|
||||
}
|
||||
return urlResult(step.text), nil
|
||||
}
|
||||
|
||||
func (*upsertByKeyCaller) Format() string { return "json" }
|
||||
func (c *upsertByKeyCaller) DryRun() bool { return c.dryRun }
|
||||
func (*upsertByKeyCaller) Fields() string { return "" }
|
||||
func (*upsertByKeyCaller) JQ() string { return "" }
|
||||
|
||||
func runUpsertByKeyCLI(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
args := []string{
|
||||
"aitable", "+record-upsert-by-key",
|
||||
"--base-id", "base", "--table-id", "table",
|
||||
"--key-field-id", "fldKey", "--key-value", "TASK-1",
|
||||
"--cells", `{"fldStatus":"进行中"}`,
|
||||
"--yes",
|
||||
}
|
||||
args = append(args, extra...)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyCreateE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[{"recordId":"r1"}]}}`},
|
||||
{text: `{"data":{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err != nil {
|
||||
t.Fatalf("create upsert error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`"status": "success"`, `"action": "create"`, `"recordId": "r1"`, `"status": "verified"`} {
|
||||
if !bytes.Contains([]byte(out), []byte(want)) {
|
||||
t.Fatalf("create output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 3 || caller.calls[1].tool != "create_records" || caller.calls[2].tool != "query_records" {
|
||||
t.Fatalf("create call flow = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyUpdateE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
|
||||
{text: `{"updatedRecords":[{"recordId":"r1"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err != nil || !bytes.Contains([]byte(out), []byte(`"action": "update"`)) {
|
||||
t.Fatalf("update upsert = output:%q err:%v", out, err)
|
||||
}
|
||||
if caller.calls[1].tool != "update_records" {
|
||||
t.Fatalf("update tool = %s", caller.calls[1].tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyAmbiguousStopsBeforeWriteE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[
|
||||
{"recordId":"r1","cells":{"fldKey":"TASK-1"}},
|
||||
{"recordId":"r2","cells":{"fldKey":"TASK-1"}}
|
||||
]}`}}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err == nil || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("ambiguous upsert = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "target_ambiguous" || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("ambiguous error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyDryRunReadsAndPlansE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err := runUpsertByKeyCLI(t, caller, "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run upsert error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "query_records" {
|
||||
t.Fatalf("dry-run calls = %#v", caller.calls)
|
||||
}
|
||||
for _, want := range []string{`"status": "planned"`, `"executed": false`, `"tool": "create_records"`} {
|
||||
if !bytes.Contains([]byte(out), []byte(want)) {
|
||||
t.Fatalf("dry-run output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyWriteReplyNeedsReadBackE2E(t *testing.T) {
|
||||
t.Run("empty write recovered by proven state", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[]}`},
|
||||
{text: ""},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) || !bytes.Contains([]byte(out), []byte("proven by read-back")) {
|
||||
t.Fatalf("recovered write = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty write and absent state is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[]}`},
|
||||
{text: ""},
|
||||
{text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("unknown write = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
|
||||
t.Fatalf("unknown write error = %#v", err)
|
||||
}
|
||||
if typed.Details == nil || !strings.Contains(fmt.Sprint(typed.Details), "query the unique key again") {
|
||||
t.Fatalf("unknown create is missing safe recovery checkpoint: %#v", typed.Details)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown update remains retryable because record id is known", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
|
||||
{text: ""},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("unknown update = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || !typed.Retryable {
|
||||
t.Fatalf("unknown update error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertByKeyVerificationMismatchFailsE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"createdRecords":[{"recordId":"r1"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"错误值"}}]}`},
|
||||
}}
|
||||
out, err := runUpsertByKeyCLI(t, caller)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("verification mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordBatchCLI(t *testing.T, caller *upsertByKeyCaller, command string, records []map[string]any, extra ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
raw, err := json.Marshal(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"aitable", command, "--base-id", "base", "--table-id", "table", "--records", string(raw), "--yes"}
|
||||
args = append(args, extra...)
|
||||
root.SetArgs(args)
|
||||
err = root.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func updateFixtureRecords(start, count int, status string) []map[string]any {
|
||||
records := make([]map[string]any, 0, count)
|
||||
for index := start; index < start+count; index++ {
|
||||
records = append(records, map[string]any{
|
||||
"recordId": fmt.Sprintf("r%03d", index),
|
||||
"cells": map[string]any{"fldStatus": status},
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func recordListJSON(t *testing.T, records []map[string]any) string {
|
||||
t.Helper()
|
||||
items := make([]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, record)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"data": map[string]any{"records": items}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 101, "完成")
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":100}`},
|
||||
{text: recordListJSON(t, records[:100])},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: recordListJSON(t, records[100:])},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
|
||||
if err != nil {
|
||||
t.Fatalf("record update batches error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`"processedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`} {
|
||||
if !bytes.Contains([]byte(out), []byte(want)) {
|
||||
t.Fatalf("batch output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 4 || caller.calls[0].tool != "update_records" || caller.calls[1].tool != "query_records" || caller.calls[2].tool != "update_records" {
|
||||
t.Fatalf("batch call sequence = %#v", caller.calls)
|
||||
}
|
||||
firstBatch := caller.calls[0].args["records"].([]any)
|
||||
secondBatch := caller.calls[2].args["records"].([]any)
|
||||
if len(firstBatch) != 100 || len(secondBatch) != 1 {
|
||||
t.Fatalf("batch sizes = %d/%d", len(firstBatch), len(secondBatch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpdatePartialStopsWithCheckpointE2E(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 101, "完成")
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":100}`},
|
||||
{text: recordListJSON(t, records[:100])},
|
||||
{err: errors.New("connection reset after send")},
|
||||
{text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("partial record update = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" {
|
||||
t.Fatalf("partial error = %#v", err)
|
||||
}
|
||||
result := typed.Details["result"].(compositeResult)
|
||||
if result.CompletedCount != 100 || result.Checkpoint["nextOffset"] != 100 {
|
||||
t.Fatalf("partial result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpdateEmptyWriteReplyRecoveredOnlyByReadBackE2E(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 1, "完成")
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: ""},
|
||||
{text: recordListJSON(t, records)},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
|
||||
if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) {
|
||||
t.Fatalf("empty write reply recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertRequiresCreatedIDsAndReadBackE2E(t *testing.T) {
|
||||
records := []map[string]any{
|
||||
{"recordId": "existing", "cells": map[string]any{"fldStatus": "完成"}},
|
||||
{"cells": map[string]any{"fldKey": "new"}},
|
||||
}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"createdRecords":[{"recordId":"created"}]}}`},
|
||||
{text: recordListJSON(t, []map[string]any{records[0]})},
|
||||
{text: recordListJSON(t, []map[string]any{{"recordId": "created", "cells": map[string]any{"fldKey": "new"}}})},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", records)
|
||||
if err != nil || !bytes.Contains([]byte(out), []byte(`"verifiedCount": 2`)) {
|
||||
t.Fatalf("mixed upsert = output:%q err:%v", out, err)
|
||||
}
|
||||
|
||||
caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true}`}}}
|
||||
out, err = runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"fldKey": "new"}}})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("upsert without created IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Retryable {
|
||||
t.Fatalf("upsert create with unknown effect must not be blindly retryable: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchDryRunPlansWithoutWritesE2E(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 101, "完成")
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", records, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("batch dry-run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte(`"status": "planned"`)) || !bytes.Contains([]byte(out), []byte(`"count": 100`)) || !bytes.Contains([]byte(out), []byte(`"count": 1`)) {
|
||||
t.Fatalf("batch dry-run output = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordDeleteCLI(t *testing.T, caller *upsertByKeyCaller, ids []string, extra ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
args := []string{"aitable", "+record-delete", "--base-id", "base", "--table-id", "table", "--record-ids", strings.Join(ids, ","), "--yes"}
|
||||
args = append(args, extra...)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func recordIDFixtures(count int) []string {
|
||||
ids := make([]string, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
ids = append(ids, fmt.Sprintf("r%03d", index))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("record delete batches error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`"deletedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`, `"status": "verified_absent"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("delete output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" {
|
||||
t.Fatalf("delete call sequence = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
|
||||
t.Run("legal empty collection proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
|
||||
t.Fatalf("delete recovered = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing records contract is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete missing records = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
|
||||
t.Fatalf("delete missing records error = %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remaining record is not success", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete remaining record = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("partial delete = output:%q err:%v", out, err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" {
|
||||
t.Fatalf("partial delete error = %#v", err)
|
||||
}
|
||||
result := typed.Details["result"].(compositeResult)
|
||||
if result.Checkpoint["nextOffset"] != 100 || result.CompletedCount != 100 {
|
||||
t.Fatalf("partial delete result = %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITableProjectionShapeFailures(t *testing.T) {
|
||||
if _, err := resolveNamedList("op", nil, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("nil list response must fail")
|
||||
}
|
||||
if _, err := resolveNamedList("op", map[string]any{"data": "bad"}, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("non-object envelope must fail")
|
||||
}
|
||||
if _, err := resolveNamedList("op", map[string]any{"data": map[string]any{"other": []any{}}}, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("unrecognized envelope must fail")
|
||||
}
|
||||
for name, project := range map[string]func() error{
|
||||
"base": func() error {
|
||||
_, err := baseListProject("list_bases", map[string]any{"bases": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"template": func() error {
|
||||
_, err := templateSearchProject(map[string]any{"templates": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"view": func() error {
|
||||
_, err := viewGetProject(map[string]any{"views": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"form": func() error {
|
||||
_, err := formListProject(map[string]any{"views": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"workflow": func() error {
|
||||
_, err := workflowListProject(map[string]any{"workflows": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := project(); err == nil {
|
||||
t.Fatal("non-object projection item must fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *testing.T) {
|
||||
bases, err := baseListProject("list_bases", map[string]any{"bases": []any{map[string]any{"baseId": "b", "baseName": "Base"}}})
|
||||
if err != nil || len(bases) != 1 {
|
||||
t.Fatalf("base projection = %#v, %v", bases, err)
|
||||
}
|
||||
if _, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"name": "missing"}}}); err == nil {
|
||||
t.Fatal("template missing ID succeeded")
|
||||
}
|
||||
templates, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"templateId": "t"}}})
|
||||
if err != nil || len(templates) != 1 {
|
||||
t.Fatalf("template projection = %#v, %v", templates, err)
|
||||
}
|
||||
views, err := viewGetProject(map[string]any{"views": []any{map[string]any{"viewId": "v"}}})
|
||||
if err != nil || len(views) != 1 {
|
||||
t.Fatalf("view projection = %#v, %v", views, err)
|
||||
}
|
||||
forms, err := formListProject(map[string]any{"views": []any{map[string]any{"viewId": "f"}}})
|
||||
if err != nil || len(forms) != 1 {
|
||||
t.Fatalf("form projection = %#v, %v", forms, err)
|
||||
}
|
||||
workflows, err := workflowListProject(map[string]any{"workflows": []any{map[string]any{"workflowId": "w"}}})
|
||||
if err != nil || len(workflows) != 1 {
|
||||
t.Fatalf("workflow projection = %#v, %v", workflows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
|
||||
err := compositeError(newCompositeResult("test"), errors.New("cause"), true)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
||||
t.Fatalf("composite default status = %v", err)
|
||||
}
|
||||
unreviewed := shortcut.Shortcut{Command: "+not-reviewed"}
|
||||
if got := withReviewedAITableShortcutContracts(unreviewed); len(got) != 1 || !got[0].Contract.Empty() {
|
||||
t.Fatalf("unreviewed shortcut changed: %#v", got)
|
||||
}
|
||||
value := shortcut.Shortcut{
|
||||
Command: "+base-list",
|
||||
Description: "fallback description",
|
||||
Tips: []string{"one", "two", "three"},
|
||||
}
|
||||
contract := reviewedAITableShortcutContract(value)
|
||||
if contract.Description != "fallback description" || len(contract.Selection.Examples) != 2 {
|
||||
t.Fatalf("reviewed contract fallback = %#v", contract)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageURLResolveAllVerificationKindsE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
tool string
|
||||
text string
|
||||
}{
|
||||
{name: "record", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26recordId%3Drecord", tool: "query_records", text: `{"records":[{"record_id":"record"}]}`},
|
||||
{name: "view", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26viewId%3Dview", tool: "get_views", text: `{"views":[{"view_id":"view"}]}`},
|
||||
{name: "base by id", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"base_id":"base"}`},
|
||||
{name: "base structural fallback", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"data":{"tables":[]}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(tc.text)}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", tc.url, "--verify")
|
||||
if err != nil || !strings.Contains(out, `"verified": true`) || caller.calls[0].tool != tc.tool {
|
||||
t.Fatalf("verification = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("tool error", func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{errors: []error{errors.New("verification failed")}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("tool error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base unknown response", func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(`{"success":true}`)}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("unknown base = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageURLResolveRecursiveShapeHelpers(t *testing.T) {
|
||||
value := map[string]any{"data": []any{map[string]any{"id": "wanted"}}}
|
||||
if !responseContainsID(value, "wanted", "id") || responseContainsID(value, "other", "id") {
|
||||
t.Fatal("responseContainsID recursion mismatch")
|
||||
}
|
||||
if !responseHasAnyKey(map[string]any{"data": []any{map[string]any{"tables": []any{}}}}, "tables") {
|
||||
t.Fatal("responseHasAnyKey recursion mismatch")
|
||||
}
|
||||
if responseHasAnyKey([]any{"value"}, "tables") {
|
||||
t.Fatal("unrelated key must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "invalid config", config: `{`},
|
||||
{name: "empty config", config: `{}`},
|
||||
{name: "preflight error", config: `{"f":1}`, steps: []upsertByKeyStep{{err: errors.New("views failed")}}},
|
||||
{name: "preflight missing list", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "matched missing id", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewName":"X","viewType":"Grid"}]}`}}},
|
||||
{name: "matched type conflict", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Kanban"}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", tc.config, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("validation = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"views":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
|
||||
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
write upsertByKeyStep
|
||||
readBack upsertByKeyStep
|
||||
}{
|
||||
{name: "read error", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
|
||||
{name: "missing list", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{}`}},
|
||||
{name: "missing exact name", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[]}`}},
|
||||
{name: "identity mismatch", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[{"viewId":"other","viewName":"X","viewType":"Grid","config":{"f":1}}]}`}},
|
||||
{name: "dual failure", write: upsertByKeyStep{err: errors.New("write failed")}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: preflight}, tc.write, tc.readBack}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("read-back failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: preflight}, {text: `{"updated":1}`}, {text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":1}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"action": "update"`) {
|
||||
t.Fatalf("update success = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
|
||||
if presetViewMatches(map[string]any{"viewType": "Kanban", "config": map[string]any{}}, "Grid", map[string]any{}) {
|
||||
t.Fatal("type mismatch must fail")
|
||||
}
|
||||
if mapContains(map[string]any{}, map[string]any{"missing": 1}) {
|
||||
t.Fatal("missing key must fail")
|
||||
}
|
||||
if mapContains(map[string]any{"nested": 1}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested type mismatch must fail")
|
||||
}
|
||||
if mapContains(map[string]any{"nested": map[string]any{}}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested value mismatch must fail")
|
||||
}
|
||||
if !mapContains(map[string]any{"nested": map[string]any{"x": 1, "extra": true}}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested subset must match")
|
||||
}
|
||||
if mapContains(map[string]any{"x": 1}, map[string]any{"x": 2}) {
|
||||
t.Fatal("scalar mismatch must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dsl string
|
||||
extra []string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "invalid DSL", dsl: `{`},
|
||||
{name: "wrong DSL version", dsl: `{"version":"v0"}`},
|
||||
{name: "update preflight error", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{err: errors.New("preflight failed")}}},
|
||||
{name: "update target not found", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{text: `{"flowId":"other"}`}}},
|
||||
{name: "publish missing ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true}`}}},
|
||||
{name: "publish ID wrong type", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":1}`}}},
|
||||
{name: "read-back error", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {err: errors.New("read failed")}}},
|
||||
{name: "read-back wrong ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"other"}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--dsl", tc.dsl}
|
||||
args = append(args, tc.extra...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+workflow-deploy", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("workflow failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "enable and verify") {
|
||||
t.Fatalf("workflow dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowEnableErrorCanStillBeVerifiedE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`},
|
||||
{err: errors.New("enable reply failed")}, {text: `{"list":[{"flowId":"w","enabled":true}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err != nil || !strings.Contains(out, "enable response error") {
|
||||
t.Fatalf("enable recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowListFailureShapesE2E(t *testing.T) {
|
||||
for name, step := range map[string]upsertByKeyStep{
|
||||
"list error": {err: errors.New("list failed")},
|
||||
"missing list": {text: `{}`},
|
||||
"not present": {text: `{"list":[]}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`}, {text: `{"enabled":true}`}, step,
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("list failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowListPaginationAndBoundE2E(t *testing.T) {
|
||||
page := make([]any, 100)
|
||||
for index := range page {
|
||||
page[index] = map[string]any{"flowId": fmt.Sprintf("other-%d", index)}
|
||||
}
|
||||
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
|
||||
switch index {
|
||||
case 0:
|
||||
return `{"valid":true,"flowId":"w"}`, nil
|
||||
case 1:
|
||||
return `{"flowId":"w"}`, nil
|
||||
case 2:
|
||||
return `{"enabled":true}`, nil
|
||||
case 3:
|
||||
return mustJSONText(t, map[string]any{"list": page}), nil
|
||||
default:
|
||||
return `{"list":[{"flowId":"w","status":"ACTIVE"}]}`, nil
|
||||
}
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"running": true`) || len(caller.calls) != 5 {
|
||||
t.Fatalf("paginated list = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
|
||||
fullPage := mustJSONText(t, map[string]any{"list": page})
|
||||
caller = &upsertByKeyCaller{callFn: func(index int, _, _ string, _ map[string]any) (string, error) {
|
||||
if index == 0 {
|
||||
return `{"valid":true,"flowId":"w"}`, nil
|
||||
}
|
||||
if index == 1 {
|
||||
return `{"flowId":"w"}`, nil
|
||||
}
|
||||
if index == 2 {
|
||||
return `{"enabled":true}`, nil
|
||||
}
|
||||
return fullPage, nil
|
||||
}}
|
||||
out, err = runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 103 {
|
||||
t.Fatalf("workflow list bound = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowPureResponseHelpers(t *testing.T) {
|
||||
if _, _, _, err := workflowPublishResult(nil); err == nil {
|
||||
t.Fatal("nil publish result must fail")
|
||||
}
|
||||
if value, found := findBoolByKeys(map[string]any{"data": []any{map[string]any{"enabled": true}}}, "enabled"); !found || !value {
|
||||
t.Fatal("nested bool not found")
|
||||
}
|
||||
if _, found := findBoolByKeys([]any{"value"}, "enabled"); found {
|
||||
t.Fatal("unrelated bool unexpectedly found")
|
||||
}
|
||||
for _, workflow := range []map[string]any{
|
||||
{"status": "enabled"}, {"state": "active"}, {"isEnabled": true},
|
||||
} {
|
||||
if !workflowIsRunning(workflow) {
|
||||
t.Fatalf("workflow should be running: %#v", workflow)
|
||||
}
|
||||
}
|
||||
if workflowIsRunning(map[string]any{"status": "STOP", "enabled": false}) {
|
||||
t.Fatal("stopped workflow must not be running")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const reviewedAITableShortcutInterfaceReason = "Reviewed AI Table Shortcut adapter: the executable CLI owns validation, orchestration, output projection, and confirmation; no single pinned MCP interface represents the complete command contract."
|
||||
|
||||
// reviewedAITableShortcutContractCommands is the exact migration ledger for
|
||||
// the 53 executable AI Table shortcuts that were hidden only because they
|
||||
// predated typed Contract declarations. New shortcuts must declare Safety and
|
||||
// Contract directly and must never be added to this migration ledger.
|
||||
var reviewedAITableShortcutContractCommands = map[string]struct{}{
|
||||
"+advperm-disable": {},
|
||||
"+advperm-enable": {},
|
||||
"+attachment-upload": {},
|
||||
"+base-copy": {},
|
||||
"+base-delete": {},
|
||||
"+base-get-primary-doc-id": {},
|
||||
"+base-update": {},
|
||||
"+chart-delete": {},
|
||||
"+chart-share-get": {},
|
||||
"+chart-share-update": {},
|
||||
"+chart-update": {},
|
||||
"+dashboard-arrange": {},
|
||||
"+dashboard-delete": {},
|
||||
"+dashboard-share-get": {},
|
||||
"+dashboard-share-update": {},
|
||||
"+dashboard-update": {},
|
||||
"+export-data": {},
|
||||
"+field-delete": {},
|
||||
"+field-update": {},
|
||||
"+form-delete": {},
|
||||
"+form-field-hide": {},
|
||||
"+form-field-update": {},
|
||||
"+form-share-update": {},
|
||||
"+form-update": {},
|
||||
"+import-data": {},
|
||||
"+import-upload": {},
|
||||
"+record-delete": {},
|
||||
"+record-primary-doc-create": {},
|
||||
"+record-primary-doc-get": {},
|
||||
"+record-update": {},
|
||||
"+record-upsert": {},
|
||||
"+role-create": {},
|
||||
"+role-delete": {},
|
||||
"+role-get": {},
|
||||
"+role-update": {},
|
||||
"+section-create": {},
|
||||
"+section-delete": {},
|
||||
"+section-move-node": {},
|
||||
"+section-rename": {},
|
||||
"+section-reorder": {},
|
||||
"+table-delete": {},
|
||||
"+table-update": {},
|
||||
"+view-delete": {},
|
||||
"+view-duplicate": {},
|
||||
"+view-lock": {},
|
||||
"+view-set-fill-color-rule": {},
|
||||
"+view-set-frozen-cols": {},
|
||||
"+view-set-row-height": {},
|
||||
"+view-update": {},
|
||||
"+workflow-disable": {},
|
||||
"+workflow-enable": {},
|
||||
"+workflow-get": {},
|
||||
"+workflow-list": {},
|
||||
}
|
||||
|
||||
func withReviewedAITableShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut {
|
||||
out := make([]shortcut.Shortcut, len(values))
|
||||
for index, value := range values {
|
||||
out[index] = value
|
||||
if !value.Contract.Empty() {
|
||||
continue
|
||||
}
|
||||
if _, reviewed := reviewedAITableShortcutContractCommands[value.Command]; !reviewed {
|
||||
continue
|
||||
}
|
||||
out[index].Safety = reviewedAITableShortcutSafety(value.Risk)
|
||||
out[index].Contract = reviewedAITableShortcutContract(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func reviewedAITableShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
return contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
}
|
||||
case shortcut.RiskHighWrite:
|
||||
return contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
}
|
||||
default:
|
||||
return contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reviewedAITableShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_")
|
||||
cliPath := "aitable " + value.Command
|
||||
description := strings.TrimSpace(value.Intent)
|
||||
if description == "" {
|
||||
description = strings.TrimSpace(value.Description)
|
||||
}
|
||||
examples := append([]string(nil), value.Tips...)
|
||||
if len(examples) > 2 {
|
||||
examples = examples[:2]
|
||||
}
|
||||
return corecmd.ContractDecl{
|
||||
Title: value.Description,
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: reviewedAITableShortcutInterfaceReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: value.Description,
|
||||
UseWhen: []string{description},
|
||||
AvoidWhen: []string{
|
||||
"只需未经适配的底层参数或原始响应时改用对应 aitable 原子命令;电子表格单元格操作改用 sheet",
|
||||
},
|
||||
Examples: examples,
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: name,
|
||||
CanonicalPath: "aitable." + name,
|
||||
CLIPath: cliPath,
|
||||
PrimaryCLIPath: cliPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITableLegacyContractLedgerIsExact(t *testing.T) {
|
||||
if got, want := len(reviewedAITableShortcutContractCommands), 53; got != want {
|
||||
t.Fatalf("legacy contract migration ledger = %d, want %d", got, want)
|
||||
}
|
||||
for command := range reviewedAITableShortcutContractCommands {
|
||||
found := false
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "aitable" && item.Command == command {
|
||||
found = true
|
||||
if item.Contract.Empty() {
|
||||
t.Errorf("%s: migration did not deliver Contract", command)
|
||||
}
|
||||
if item.Safety.Confirmation == "" {
|
||||
t.Errorf("%s: migration did not deliver explicit Safety", command)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("stale migration ledger entry %s", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user