fix: localize plugin operation errors

This commit is contained in:
2026-07-14 22:00:24 +08:00
parent 1f03226378
commit a1b3c31d0d
37 changed files with 330 additions and 154 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function frontendSources(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return frontendSources(target);
return /\.(?:js|svelte)$/.test(entry.name) ? [target] : [];
});
}
const files = fs.readdirSync(path.join(root, 'plugins'))
.flatMap((plugin) => frontendSources(path.join(root, 'plugins', plugin, 'frontend', 'src')));
const renderPatterns = [
/statusText\s*=/,
/state\.error\s*=/,
/errorMsg\s*=/,
/setStatus\(/,
/setCreateError\(/,
/setRenameError\(/,
/renderEmpty\(/,
/output\.errors\.push\(/,
/window\.alert\(/,
/showExternalFallback\(/,
/de-error-msg/,
/files-error-msg/,
/body\.textContent\s*=/
];
const violations = [];
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, index) => {
if (!/(?:err(?:or)?\.message|String\(err(?:or)?\b)/.test(line)) return;
if (!renderPatterns.some((pattern) => pattern.test(line))) return;
violations.push(`${path.relative(root, file)}:${index + 1}: technical error text reaches a user-facing UI sink`);
});
}
if (violations.length) {
console.error('User-facing UI must not render raw backend/plugin errors:');
violations.forEach((violation) => console.error(` ${violation}`));
process.exit(1);
}
console.log('user-facing error messages do not expose raw backend details');
+12
View File
@@ -101,6 +101,18 @@ else
echo " ⚠️ node not available — skipping select style validation"
fi
echo ""
echo "[user-facing errors]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-user-facing-errors.js"
STATUS=$?
set -e
report "user-facing errors" "$STATUS"
else
echo " ⚠️ node not available — skipping user-facing error validation"
fi
echo ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"
+20 -4
View File
@@ -6,6 +6,7 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@@ -113,7 +114,15 @@ function makeDocument() {
function loadComponents(document) {
const registry = {};
const sandbox = {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
Date,
document,
window: {
@@ -738,9 +747,12 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue');
}
if (!failedConversionView.container.textContent.includes('Could not create note')) {
if (!failedConversionView.container.textContent.includes('Could not create the note. Please try again.')) {
throw new Error('failed conversion did not render an error status');
}
if (failedConversionView.container.textContent.includes('file already exists')) {
throw new Error('failed conversion exposed a raw backend error');
}
if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed conversion published converted event');
}
@@ -827,7 +839,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue');
}
if (!failedLinkView.container.textContent.includes('Could not create link')) {
if (!failedLinkView.container.textContent.includes('Could not create the link. Please try again.')) {
throw new Error('failed link conversion did not render an error status');
}
if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@@ -934,7 +946,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue');
}
if (!failedFileView.container.textContent.includes('Could not create file')) {
if (!failedFileView.container.textContent.includes('Could not create the file. Please try again.')) {
throw new Error('failed file conversion did not render an error status');
}
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@@ -942,6 +954,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
component.unmount && component.unmount(failedFileView.container);
if (!technicalErrors.some((entry) => entry.includes('file already exists'))) {
throw new Error('failed conversion did not retain its technical details in the console log');
}
console.log('browser inbox plugin smoke passed');
})().catch((err) => {
console.error(err);
+13 -2
View File
@@ -8,6 +8,7 @@ const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'inde
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@@ -100,7 +101,15 @@ function makeDocument() {
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
@@ -253,11 +262,13 @@ async function wait(ms) {
if (!container.textContent.includes('Project/Target Assets')) throw new Error('typing should search folder paths');
if (!container.textContent.includes('Project/External/target.note')) throw new Error('external provider result should be rendered');
if (!container.textContent.includes('External Notes')) throw new Error('external provider label should be rendered');
if (!container.textContent.includes('provider unavailable')) throw new Error('provider failure should be reported without failing search');
if (!container.textContent.includes('A search provider is unavailable.')) throw new Error('provider failure should be reported without failing search');
if (container.textContent.includes('provider unavailable')) throw new Error('provider failure leaked a raw backend error');
if (!container.textContent.includes('Content match')) throw new Error('content result type was not rendered');
if (!container.textContent.includes('Folder name')) throw new Error('folder result type was not rendered');
if (!pluginData['search-index'] || !Array.isArray(pluginData['search-index'].files)) throw new Error('search index was not written to plugin data storage');
if (providerCalls.some((call) => call.pluginId === 'verstak.search')) throw new Error('search must not call itself as an external provider');
if (!technicalErrors.some((entry) => entry.includes('provider unavailable'))) throw new Error('provider failure was not retained in the console log');
input = queryInput();
input.value = 'image';
+3
View File
@@ -101,6 +101,9 @@ function loadComponent(document, errorLog) {
vm.runInNewContext(source, {
console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
+8 -5
View File
@@ -9,14 +9,17 @@ const source = fs.readFileSync(sourcePath, 'utf8');
if (!source.includes('settings.lastError')) {
throw new Error('SyncSettings must render persisted settings.lastError');
}
if (!source.includes('sanitizeError(settings.lastError)')) {
throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
if (!source.includes('function reportError')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors');
}
if (!source.includes('Last sync error')) {
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) {
throw new Error('SyncSettings must not interpolate raw technical errors into user-facing messages');
}
if (!source.includes("tr('ui.lastSyncError'")) {
throw new Error('SyncSettings must label the persisted sync error');
}
if (!source.includes('function formatSyncConflict')) {
throw new Error('SyncSettings must format individual sync conflicts');
if (!source.includes("tr('ui.syncConflictItem'")) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary');
}
if (!source.includes('conflictDetails')) {
throw new Error('SyncSettings must store sync conflict details after Sync Now');