feat: add private help and feedback reports (#43)

Issue: #43
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-02 02:17:35 +03:00
parent 7490575799
commit 1e2e0fa61c
48 changed files with 3426 additions and 366 deletions
+2 -2
View File
@@ -64,10 +64,10 @@ test('all dangerous-action call sites use the shared confirmation contract', ()
const sharedCalls = source.match(/await this(?:\.host)?\._confirmDanger\s*\(\{/g) || [];
assert.equal(nativeCalls.length, 0, 'native browser confirmation must not return');
assert.equal(sharedCalls.length, 8, 'the reviewed inventory stays on the shared surface');
assert.equal(sharedCalls.length, 9, 'the reviewed inventory stays on the shared surface');
for (const key of [
'delete-draft', 'delete-draft-segment', 'remove-marker',
'delete-plan', 'delete-space', 'unlock',
'delete-plan', 'delete-space', 'unlock', 'close-support-busy',
]) {
assert.match(source, new RegExp(`key: '${key}'`));
}
+11
View File
@@ -42,6 +42,7 @@ test('paths classify into A/B/C/D with the generated tree winning over source',
assert.equal(classify('demo/golden/baselines/view.png'), 'D');
assert.equal(classify('dist/houseplan-card.js'), 'D');
assert.equal(classify('test/canvas.test.mjs'), 'B');
assert.equal(classify('scripts/support-relay/relay.py'), 'B');
assert.equal(classify('.github/workflows/validate.yml'), 'B');
assert.equal(classify('package-lock.json'), 'B');
assert.equal(classify('docs/SCOPE.md'), 'C');
@@ -712,6 +713,16 @@ test('an infrastructure range is recognised by the absence of class A files (#20
assert.equal(isInfrastructureRange([]), false);
});
test('a support-relay-only change stays on the reviewed class-B track (#43)', () => {
const relay = makeCommit({
sha: 'd'.repeat(40), subject: 'Harden private support relay',
body: 'Issue: #43\nUser-Visible: no',
files: ['scripts/support-relay/hp_relay/app.py', 'scripts/support-relay/tests/test_relay.py'],
});
assert.deepEqual([...relay.classes], ['B']);
assert.equal(isInfrastructureRange([relay]), true);
});
test('statusOptional waives the status label but keeps every other rule-8 refusal (#207)', () => {
// Метки инфраструктурного issue по #118: тип, приоритет, тема — без S*.
const infraIssue = () => ({
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
codePointLength,
newSupportDialogState,
supportCanSubmit,
supportDraftError,
supportErrorCode,
supportRuntimeFacts,
} from '../test-build/support-feedback.js';
test('a fresh dialog never opts into exact plan geometry', () => {
const state = newSupportDialogState();
assert.equal(state.attach, false);
assert.equal(state.preview, null);
assert.equal(state.contact, '');
assert.equal(state.message, '');
assert.match(state.draftId, /^draft-/);
assert.match(state.idempotencyKey, /^report-/);
});
test('message/contact limits count Unicode code points rather than UTF-16 units', () => {
assert.equal(codePointLength('🙂'), 1);
const state = { ...newSupportDialogState(), message: 'ok', contact: '🙂'.repeat(321) };
assert.equal(supportDraftError(state), 'contact_too_long');
assert.equal(supportCanSubmit(state), false);
});
test('attachment submit requires the exact non-expired preview', () => {
const now = 10_000;
const base = { ...newSupportDialogState(), message: 'repro', attach: true };
assert.equal(supportDraftError(base, now), 'preview_missing');
const preview = {
token: 'a'.repeat(48), expiresAt: now + 1, size: 2, sha256: 'b'.repeat(64),
spaces: 1, format: 'houseplan-support-package', version: 1, text: '{}\n', preparedAt: now,
};
assert.equal(supportCanSubmit({ ...base, preview, status: 'ready' }, now), true);
assert.equal(supportDraftError({ ...base, preview }, now + 1), 'preview_expired');
});
test('runtime facts are bounded enums without a raw user agent', () => {
const facts = supportRuntimeFacts({
userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 private-suffix',
language: 'ru', coarsePointer: false, hoverCapable: true,
registryAccess: 'full', registryLastSuccess: 9_500, now: 10_000,
});
assert.deepEqual(facts, {
browser_family: 'chromium', browser_major: 140, language: 'ru',
coarse_pointer: false, hover_capable: true, registry_access: 'full',
registry_age_bucket: 'fresh',
});
assert.equal(JSON.stringify(facts).includes('private-suffix'), false);
});
test('remote and unknown failures collapse to stable local error codes', () => {
assert.equal(supportErrorCode({ code: 'support_rate_limited', message: 'private' }), 'support_rate_limited');
assert.equal(supportErrorCode({ code: 'provider leaked details' }), 'support_unavailable');
assert.equal(supportErrorCode(new Error('network secret')), 'support_unavailable');
});
test('Help is lazy, ordered after settings, and owns the single About/Guide surface', () => {
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const runtime = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles/plan.styles.ts', import.meta.url), 'utf8');
const header = card.slice(card.indexOf('<div class="zoomctl">'), card.indexOf('</div>\n ${this._canEdit'));
assert.ok(header.indexOf('_openSettingsDialog') < header.indexOf('_openSupportDialog'));
assert.match(card, /if \(!this\._editorRuntime\)[\s\S]*?_ensureEditorRuntime\(\)[\s\S]*?_openSupportDialog/);
assert.equal((runtime.match(/_t\('gs\.about_version'/g) || []).length, 1);
assert.match(runtime, /docs\/USER-GUIDE\.ru\.md/);
assert.match(runtime, /docs\/USER-GUIDE\.md/);
assert.match(styles, /\.support-button\s*\{[\s\S]*?min-width:\s*44px;[\s\S]*?min-height:\s*44px;/);
});
test('the consent copy names exact geometry, project relay, retention and network address', () => {
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url), 'utf8'));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url), 'utf8'));
assert.match(en['support.privacy'], /exact geometry/);
assert.match(en['support.privacy'], /project relay/);
assert.match(en['support.privacy'], /30 days/);
assert.match(en['support.privacy'], /server address/);
assert.equal(
ru['support.contact'],
'Контакт для связи (email/tg/WhatsApp), необязательно.',
);
});