@sanqianx/project-knowledge 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +780 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +266 -0
- package/_site/README.md +30 -0
- package/_site/_test/ai-profile-resolver-test.js +137 -0
- package/_site/_test/ai-profile-test.js +75 -0
- package/_site/_test/ai-vendor-presets-test.js +91 -0
- package/_site/_test/automation-queue-test.js +14 -0
- package/_site/_test/automation-ui-test.js +40 -0
- package/_site/_test/background-task-registry-test.js +43 -0
- package/_site/_test/baseline-schema-test.js +91 -0
- package/_site/_test/bin-cli-test.js +218 -0
- package/_site/_test/bridge-adapter-test.js +95 -0
- package/_site/_test/bridge-consumer-service-test.js +310 -0
- package/_site/_test/cdp-browser-failure-test.js +100 -0
- package/_site/_test/chat-claudecodeui-match-test.js +92 -0
- package/_site/_test/claude-executable-discovery-test.js +90 -0
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -0
- package/_site/_test/claude-workbench-test.js +170 -0
- package/_site/_test/client-capture-restart-e2e-test.js +189 -0
- package/_site/_test/codex-conversation-projection-test.js +59 -0
- package/_site/_test/codex-notify-fanout-test.js +45 -0
- package/_site/_test/commit-boundary-freeze-test.js +147 -0
- package/_site/_test/commit-conversation-binding-test.js +99 -0
- package/_site/_test/commit-evidence-test.js +110 -0
- package/_site/_test/context-pack-test.js +198 -0
- package/_site/_test/conversation-api-test.js +119 -0
- package/_site/_test/conversation-store-test.js +136 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -0
- package/_site/_test/data-dir-migration-test.js +77 -0
- package/_site/_test/desktop-browser-compat-test.js +28 -0
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -0
- package/_site/_test/embedding-config-test.js +32 -0
- package/_site/_test/explicit-commit-processor-test.js +37 -0
- package/_site/_test/fixtures/fake-browser-exit.js +10 -0
- package/_site/_test/fixtures/fake-browser-hang.js +10 -0
- package/_site/_test/fixtures/make-git-repos.js +89 -0
- package/_site/_test/folder-picker-output-test.js +19 -0
- package/_site/_test/full-integration-e2e-test.js +165 -0
- package/_site/_test/git-validation-test.js +81 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -0
- package/_site/_test/hook-status-repair-api-test.js +152 -0
- package/_site/_test/hook-trigger-test.js +85 -0
- package/_site/_test/hook-worktree-test.js +42 -0
- package/_site/_test/import-preflight-api-test.js +233 -0
- package/_site/_test/import-ui-flow-test.js +171 -0
- package/_site/_test/index-builder-test.js +41 -0
- package/_site/_test/index-writer-concurrency-test.js +135 -0
- package/_site/_test/integration-adapters-test.js +243 -0
- package/_site/_test/integration-capture-setup-test.js +108 -0
- package/_site/_test/integration-surface-coverage-test.js +165 -0
- package/_site/_test/kb-framework-test.js +57 -0
- package/_site/_test/kbpath-follow-test.js +63 -0
- package/_site/_test/knowledge-db-test.js +144 -0
- package/_site/_test/knowledge-language-control-test.js +170 -0
- package/_site/_test/knowledge-maintenance-test.js +40 -0
- package/_site/_test/knowledge-migration-test.js +75 -0
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -0
- package/_site/_test/knowledge-query-test.js +54 -0
- package/_site/_test/knowledge-retrieval-service-test.js +77 -0
- package/_site/_test/knowledge-scopes-test.js +37 -0
- package/_site/_test/knowledge-storage-location-test.js +28 -0
- package/_site/_test/knowledge-storage-startup-test.js +52 -0
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -0
- package/_site/_test/legacy-claude-session-alias-test.js +106 -0
- package/_site/_test/legacy-forward-compat-test.js +257 -0
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -0
- package/_site/_test/legacy-routes-removed-test.js +47 -0
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -0
- package/_site/_test/log-redaction-test.js +58 -0
- package/_site/_test/logging-api-test.js +89 -0
- package/_site/_test/logging-sse-no-gap-test.js +88 -0
- package/_site/_test/logging-ui-test.js +104 -0
- package/_site/_test/markdown-delta-overlay-test.js +85 -0
- package/_site/_test/markdown-maintenance-api-test.js +75 -0
- package/_site/_test/markdown-maintenance-test.js +11 -0
- package/_site/_test/mcp-server-test.js +149 -0
- package/_site/_test/model-context-windows-test.js +89 -0
- package/_site/_test/module-bridge-eventbridge-test.js +193 -0
- package/_site/_test/non-release-ci-test.js +34 -0
- package/_site/_test/offline-boundary-isolation-test.js +32 -0
- package/_site/_test/orphaned-claim-recovery-test.js +80 -0
- package/_site/_test/p0-data-migration-characterization-test.js +37 -0
- package/_site/_test/p0-e2e-gate-test.js +347 -0
- package/_site/_test/package-boundary-test.js +42 -0
- package/_site/_test/package-startup-test.js +56 -0
- package/_site/_test/packaged-ui-smoke-test.js +79 -0
- package/_site/_test/path-consistency-test.js +151 -0
- package/_site/_test/pending-sweep-test.js +9 -0
- package/_site/_test/post-commit-automation-test.js +87 -0
- package/_site/_test/pr-consumer-contract-test.js +55 -0
- package/_site/_test/project-control-panel-task14-test.js +63 -0
- package/_site/_test/project-delete-recovery-test.js +64 -0
- package/_site/_test/project-goal-editor-test.js +145 -0
- package/_site/_test/project-layout-v2-migration-test.js +127 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -0
- package/_site/_test/project-store-test.js +90 -0
- package/_site/_test/prompt-settings-test.js +115 -0
- package/_site/_test/protected-architecture-gate-test.js +123 -0
- package/_site/_test/refactor-characterization-test.js +36 -0
- package/_site/_test/release-version-sync-test.js +98 -0
- package/_site/_test/requirement-binding-test.js +113 -0
- package/_site/_test/requirement-recorder-test.js +173 -0
- package/_site/_test/run-all-tests.js +156 -0
- package/_site/_test/runtime-endpoint-test.js +64 -0
- package/_site/_test/scanner-test.js +83 -0
- package/_site/_test/server-runtime-migration-safety-test.js +27 -0
- package/_site/_test/server-security-test.js +96 -0
- package/_site/_test/sessions-stream-test.js +92 -0
- package/_site/_test/shared-contracts-test.js +58 -0
- package/_site/_test/simple-import-test.js +97 -0
- package/_site/_test/startup-analysis-disabled-test.js +16 -0
- package/_site/_test/storage-foundation-test.js +78 -0
- package/_site/_test/structured-logger-test.js +101 -0
- package/_site/_test/task15-20-ui-flow-test.js +148 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -0
- package/_site/_test/team-knowledge-store-test.js +131 -0
- package/_site/_test/tracking-start-test.js +143 -0
- package/_site/_test/ui-i18n-toggle-test.js +114 -0
- package/_site/_test/ui-smoke-test.js +73 -0
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -0
- package/_site/_test/workbench-permission-test.js +118 -0
- package/_site/_test/workspace-ui-contract-test.js +43 -0
- package/_site/lib/ai-profile-resolver.js +78 -0
- package/_site/lib/ai-vendor-presets.js +83 -0
- package/_site/lib/ai-workspace.js +104 -0
- package/_site/lib/atomic-file.js +204 -0
- package/_site/lib/automation-config.js +140 -0
- package/_site/lib/bridge-adapter.js +256 -0
- package/_site/lib/bridge-consumer-service.js +402 -0
- package/_site/lib/claude-cli-runner.js +1599 -0
- package/_site/lib/commit-conversation-binder.js +246 -0
- package/_site/lib/commit-processing-ledger.js +27 -0
- package/_site/lib/commit-prompt.js +202 -0
- package/_site/lib/commit-reconciler.js +444 -0
- package/_site/lib/context-pack-builder.js +299 -0
- package/_site/lib/contracts.js +279 -0
- package/_site/lib/conversation-exclusions.js +204 -0
- package/_site/lib/conversation-query-service.js +161 -0
- package/_site/lib/conversation-store.js +385 -0
- package/_site/lib/data-dir.js +81 -0
- package/_site/lib/data-state-classifier.js +40 -0
- package/_site/lib/embedding-service.js +85 -0
- package/_site/lib/evidence-bundle.js +228 -0
- package/_site/lib/folder-picker-output.js +24 -0
- package/_site/lib/git-runner.js +62 -0
- package/_site/lib/github-team-store.js +1175 -0
- package/_site/lib/hook-manager.js +202 -0
- package/_site/lib/index-builder.js +157 -0
- package/_site/lib/index-service.js +173 -0
- package/_site/lib/integration-installer.js +907 -0
- package/_site/lib/kb-framework.js +191 -0
- package/_site/lib/kb-validator.js +124 -0
- package/_site/lib/knowledge-db.js +363 -0
- package/_site/lib/knowledge-promotion.js +435 -0
- package/_site/lib/knowledge-retrieval-service.js +401 -0
- package/_site/lib/knowledge-schema.js +121 -0
- package/_site/lib/knowledge-tool-runtime.js +359 -0
- package/_site/lib/legacy-data-manifest.js +26 -0
- package/_site/lib/llm-client.js +173 -0
- package/_site/lib/markdown-knowledge-indexer.js +230 -0
- package/_site/lib/migration-service.js +442 -0
- package/_site/lib/model-context-windows.js +82 -0
- package/_site/lib/module-bridge.js +496 -0
- package/_site/lib/post-commit-automation.js +141 -0
- package/_site/lib/project-lifecycle-service.js +511 -0
- package/_site/lib/project-registry-store.js +129 -0
- package/_site/lib/project-store.js +262 -0
- package/_site/lib/prompt-registry.js +148 -0
- package/_site/lib/requirement-adapters.js +10 -0
- package/_site/lib/requirement-binder.js +112 -0
- package/_site/lib/requirement-recorder.js +278 -0
- package/_site/lib/runtime-endpoint.js +160 -0
- package/_site/lib/scanner.js +298 -0
- package/_site/lib/server-app.js +1520 -0
- package/_site/lib/settings-store.js +113 -0
- package/_site/lib/storage-layout.js +162 -0
- package/_site/lib/structured-logger.js +574 -0
- package/_site/scripts/codex-notify-fanout.cjs +51 -0
- package/_site/scripts/folder-picker.ps1 +156 -0
- package/_site/scripts/hook-trigger.js +156 -0
- package/_site/scripts/sync-release-version.js +158 -0
- package/_site/server.js +12 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/bin/project-knowledge-integrations.js +241 -0
- package/bin/project-knowledge-kb.js +65 -0
- package/bin/project-knowledge-mcp.js +195 -0
- package/bin/project-knowledge.js +439 -0
- package/claude-prompts.json +21 -0
- package/docs/README.zh-CN.md +175 -0
- package/docs/assets/architecture.svg +182 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/draft-review.png +0 -0
- package/docs/assets/logo.svg +50 -0
- package/docs/assets/runs-drafts.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/project-registry-schema.md +33 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -0
- package/docs/testing-strategy.md +33 -0
- package/package.json +100 -0
- package/plugins/project-knowledge/.claude-plugin/plugin.json +19 -0
- package/plugins/project-knowledge/.codex-plugin/plugin.json +38 -0
- package/plugins/project-knowledge/.mcp.json +14 -0
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -0
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -0
- package/plugins/project-knowledge/skills/project-knowledge/agents/openai.yaml +11 -0
- package/templates/change.md +30 -0
- package/templates/project-readme.md +52 -0
- package/ui/app.css +58 -0
- package/ui/app.js +607 -0
- package/ui/favicon.svg +38 -0
- package/ui/i18n.js +146 -0
- package/ui/index.html +162 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
6
|
+
const { findChrome } = require('./helpers/find-chrome');
|
|
7
|
+
const { launchCdpBrowser, requestJson, waitFor } = require('./helpers/cdp-browser');
|
|
8
|
+
const { createLoggingUiFixture } = require('./helpers/logging-ui-fixture');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
11
|
+
const sitePort = 8200 + (process.pid % 250);
|
|
12
|
+
const profileDir = path.join(os.tmpdir(), 'pk-ui-flow-profile-' + process.pid);
|
|
13
|
+
const artifactDir = path.join(ROOT, '.agent-state', 'ui-v13');
|
|
14
|
+
|
|
15
|
+
async function capture(browser, name) {
|
|
16
|
+
const filePath = path.join(artifactDir, name + '.png');
|
|
17
|
+
await browser.screenshot(filePath);
|
|
18
|
+
assert(fs.statSync(filePath).size > 10000, `screenshot ${name} must contain rendered UI`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
(async () => {
|
|
22
|
+
const fixture = await createLoggingUiFixture({ totalLogs: 240 });
|
|
23
|
+
const server = spawnServer({ root: ROOT, port: sitePort, dataDir: fixture.dataDir, tag: 'ui-flow-v13', stdio: 'ignore' });
|
|
24
|
+
let browser;
|
|
25
|
+
try {
|
|
26
|
+
await waitFor(() => requestJson(`http://127.0.0.1:${sitePort}/api/health`).then(body => body.ok), 'flow server', 20000);
|
|
27
|
+
browser = await launchCdpBrowser({ chrome: findChrome(), profileDir, url: `http://127.0.0.1:${sitePort}/`, width: 1366, height: 768 });
|
|
28
|
+
await waitFor(() => browser.evaluate('window.__PK_APP__ && window.__PK_APP__.getState().projects === 1'), 'full shell', 20000);
|
|
29
|
+
|
|
30
|
+
await browser.setViewport(1920, 1080);
|
|
31
|
+
await capture(browser, '01-main-shell-1920-light');
|
|
32
|
+
// T2: 聊天由内嵌终端提供 —— 壳断言嵌入视图与 iframe 挂载
|
|
33
|
+
assert.strictEqual(await browser.evaluate("document.getElementById('view-terminal').classList.contains('active')"), true);
|
|
34
|
+
assert.strictEqual(await browser.evaluate("document.getElementById('fr-5760').src.includes('/agent-terminal/')"), true);
|
|
35
|
+
await capture(browser, '02-terminal-embed-1920-light');
|
|
36
|
+
|
|
37
|
+
await browser.evaluate("document.querySelector('[data-go=\"import\"]').click()");
|
|
38
|
+
assert.strictEqual(await browser.evaluate("document.getElementById('import-path').required"), true);
|
|
39
|
+
assert.strictEqual(await browser.evaluate("document.querySelectorAll('#import-knowledge-path').length"), 1, 'Import takes an explicit knowledge address (T3.2)');
|
|
40
|
+
await capture(browser, '03-import-1920-light');
|
|
41
|
+
|
|
42
|
+
// 开发对话已迁移到 ai-coding-event-bridge 模块:设置抽屉只有日志一节
|
|
43
|
+
await browser.evaluate("window.__PK_APP__.openSettings()");
|
|
44
|
+
await waitFor(() => browser.evaluate("document.getElementById('settings-logs').classList.contains('active')"), 'logs settings');
|
|
45
|
+
assert.strictEqual(await browser.evaluate("document.querySelectorAll('[data-settings]').length"), 1, 'settings drawer must only expose logs (conversation moved to event-bridge module)');
|
|
46
|
+
await capture(browser, '04-settings-logs-1920-light');
|
|
47
|
+
|
|
48
|
+
await browser.setViewport(1366, 768);
|
|
49
|
+
await browser.evaluate("window.__PK_APP__.showSettings('logs')");
|
|
50
|
+
// 等 DOM 实际渲染出全部行(loadLogs 先清空再异步渲染,只看内存状态会撞上清空间隙)
|
|
51
|
+
await waitFor(() => browser.evaluate('window.__PK_APP__.getState().logCount >= 240 && document.querySelectorAll(".record-row").length >= 240'), '200+ log rows', 20000);
|
|
52
|
+
const auditLogs = () => browser.evaluate(`(() => {
|
|
53
|
+
const rows = [...document.querySelectorAll('.record-row')];
|
|
54
|
+
const drawer = document.getElementById('settings-drawer');
|
|
55
|
+
const list = document.getElementById('record-list');
|
|
56
|
+
const card = document.querySelector('.logs-card').getBoundingClientRect();
|
|
57
|
+
const content = document.querySelector('.settings-content').getBoundingClientRect();
|
|
58
|
+
const overlaps = rows.filter(row => {
|
|
59
|
+
const body = row.querySelector('.record-main').getBoundingClientRect();
|
|
60
|
+
const time = row.querySelector('.record-time').getBoundingClientRect();
|
|
61
|
+
return body.right + 8 > time.left;
|
|
62
|
+
}).length;
|
|
63
|
+
const scrollables = [...drawer.querySelectorAll('*')].filter(node => {
|
|
64
|
+
const style = getComputedStyle(node);
|
|
65
|
+
return /(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1;
|
|
66
|
+
}).map(node => node.id || node.className);
|
|
67
|
+
const colorParts = level => {
|
|
68
|
+
const row = document.querySelector('.record-entry[data-level=' + level + '] .record-row');
|
|
69
|
+
return row ? [row, row.querySelector('.record-title'), row.querySelector('.record-meta'), row.querySelector('.record-time')].map(node => getComputedStyle(node).color) : [];
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
rows: rows.length,
|
|
73
|
+
overlaps,
|
|
74
|
+
scrollables,
|
|
75
|
+
maxHeight: getComputedStyle(list).maxHeight,
|
|
76
|
+
listOverflow: getComputedStyle(list).overflowY,
|
|
77
|
+
cardBottomGap: Math.round(content.bottom - card.bottom),
|
|
78
|
+
warnColors: colorParts('warn'),
|
|
79
|
+
errorColors: colorParts('error'),
|
|
80
|
+
severityMarks: document.querySelectorAll('.record-entry svg, .record-entry img, .record-entry .dot, .record-entry .severity').length,
|
|
81
|
+
pageOverflow: document.body.scrollWidth > document.documentElement.clientWidth + 1,
|
|
82
|
+
recordHeight: document.querySelector('.record-shell').getBoundingClientRect().height,
|
|
83
|
+
};
|
|
84
|
+
})()`);
|
|
85
|
+
const desktopLogs = await auditLogs();
|
|
86
|
+
assert(desktopLogs.rows >= 240);
|
|
87
|
+
assert.strictEqual(desktopLogs.overlaps, 0, 'message/meta must never overlap the fixed time column');
|
|
88
|
+
assert.deepStrictEqual(desktopLogs.scrollables, ['record-list'], 'record-list must be the only vertical scroller in the active logs drawer');
|
|
89
|
+
assert(desktopLogs.maxHeight === 'none');
|
|
90
|
+
assert.strictEqual(desktopLogs.listOverflow, 'auto');
|
|
91
|
+
assert(desktopLogs.cardBottomGap <= 23, 'logs card must reach the settings content bottom');
|
|
92
|
+
assert(desktopLogs.warnColors.length === 4 && new Set(desktopLogs.warnColors).size === 1, 'warn message/meta/time must share whole-row color');
|
|
93
|
+
assert(desktopLogs.errorColors.length === 4 && new Set(desktopLogs.errorColors).size === 1, 'error message/meta/time must share whole-row color');
|
|
94
|
+
assert.strictEqual(desktopLogs.severityMarks, 0);
|
|
95
|
+
assert.strictEqual(desktopLogs.pageOverflow, false);
|
|
96
|
+
|
|
97
|
+
await browser.evaluate("document.querySelector('.record-entry[data-level=error] .record-row').click()");
|
|
98
|
+
assert.strictEqual(await browser.evaluate("document.querySelector('.record-entry[data-level=error]').classList.contains('open')"), true);
|
|
99
|
+
await capture(browser, '07-logs-1366x768-expanded-light');
|
|
100
|
+
await browser.evaluate("document.getElementById('record-list').scrollTop = 0");
|
|
101
|
+
await browser.evaluate("fetch('/api/health?ui-sse=' + Date.now()).then(response => response.json())");
|
|
102
|
+
await waitFor(() => browser.evaluate('window.__PK_APP__.getState().newLogs > 0'), 'new-record banner', 20000);
|
|
103
|
+
assert.strictEqual(await browser.evaluate("document.getElementById('new-records').hidden"), false);
|
|
104
|
+
assert.strictEqual(await browser.evaluate("document.getElementById('record-list').scrollTop"), 0, 'SSE append must not steal a scrolled-up position');
|
|
105
|
+
await capture(browser, '08-logs-scrolled-new-banner-light');
|
|
106
|
+
await browser.evaluate("document.getElementById('new-records').click()");
|
|
107
|
+
await waitFor(() => browser.evaluate("window.__PK_APP__.getState().newLogs === 0 && document.getElementById('record-list').scrollHeight - document.getElementById('record-list').scrollTop - document.getElementById('record-list').clientHeight < 2"), 'return to log bottom');
|
|
108
|
+
|
|
109
|
+
await browser.setViewport(1920, 1080);
|
|
110
|
+
const tallLogs = await auditLogs();
|
|
111
|
+
assert(tallLogs.recordHeight > desktopLogs.recordHeight + 200, 'record shell must grow with viewport height');
|
|
112
|
+
await browser.evaluate("document.getElementById('theme-button').click()");
|
|
113
|
+
assert.strictEqual(await browser.evaluate('document.documentElement.dataset.theme'), 'dark');
|
|
114
|
+
await capture(browser, '09-logs-1920x1080-dark');
|
|
115
|
+
|
|
116
|
+
await browser.setViewport(820, 900);
|
|
117
|
+
const narrowLogs = await auditLogs();
|
|
118
|
+
assert.strictEqual(narrowLogs.overlaps, 0);
|
|
119
|
+
assert.strictEqual(narrowLogs.pageOverflow, false);
|
|
120
|
+
await capture(browser, '10-logs-820x900-dark');
|
|
121
|
+
|
|
122
|
+
await browser.setViewport(390, 844);
|
|
123
|
+
const mobileLogs = await auditLogs();
|
|
124
|
+
assert.strictEqual(mobileLogs.overlaps, 0);
|
|
125
|
+
assert.strictEqual(mobileLogs.pageOverflow, false);
|
|
126
|
+
assert.strictEqual(await browser.evaluate("getComputedStyle(document.querySelector('.settings-nav')).flexDirection"), 'row');
|
|
127
|
+
await capture(browser, '11-mobile-logs-390x844-dark');
|
|
128
|
+
|
|
129
|
+
await browser.evaluate(`(() => {
|
|
130
|
+
window.__PK_APP__.showSettings('logs');
|
|
131
|
+
const card = document.querySelector('#project-list .project-card');
|
|
132
|
+
card.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 300 }));
|
|
133
|
+
})()`);
|
|
134
|
+
await waitFor(() => browser.evaluate("document.getElementById('context-menu').classList.contains('show')"), 'mobile context menu');
|
|
135
|
+
await browser.evaluate("document.getElementById('remove-project').click()");
|
|
136
|
+
await waitFor(() => browser.evaluate("document.getElementById('delete-dialog').open"), 'project delete modal');
|
|
137
|
+
await capture(browser, '13-mobile-delete-modal-dark');
|
|
138
|
+
assert.strictEqual(browser.exceptions.length, 0, 'browser runtime exceptions: ' + JSON.stringify(browser.exceptions));
|
|
139
|
+
console.log('task15-20 UI flow test PASS');
|
|
140
|
+
} finally {
|
|
141
|
+
if (browser) await browser.close();
|
|
142
|
+
await server.cleanup();
|
|
143
|
+
fixture.cleanup();
|
|
144
|
+
}
|
|
145
|
+
})().catch(error => {
|
|
146
|
+
console.error(error.stack || error.message);
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Tests for sparse-checkout automation in github-team-store.js.
|
|
2
|
+
// Style mirrors team-knowledge-store-test.js (IIFE + assert()).
|
|
3
|
+
//
|
|
4
|
+
// Three scenarios:
|
|
5
|
+
// 1. New sparse clone — checkoutStore({ subdir, partialClone }) clones with
|
|
6
|
+
// --filter=blob:none --sparse --no-checkout, applies cone, and materializes
|
|
7
|
+
// only the requested subdirectory.
|
|
8
|
+
// 2. Re-clone / switch — calling checkoutStore twice on the same local path
|
|
9
|
+
// re-applies sparse-checkout cone (idempotent).
|
|
10
|
+
// 3. Git version fallback — _setGitVersionForTests forces a low version; the
|
|
11
|
+
// partial-clone branch must fall back to a full clone and return warning.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const githubTeamStore = require('../lib/github-team-store');
|
|
19
|
+
const gitRunner = require('../lib/git-runner');
|
|
20
|
+
|
|
21
|
+
function assert(cond, msg) {
|
|
22
|
+
if (!cond) throw new Error(msg);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(cwd, args) {
|
|
26
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf-8').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function initBareRemote(cwd) {
|
|
30
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
31
|
+
git(cwd, ['init', '--bare']);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function seedRemoteWith(cwd, files) {
|
|
35
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), `kb-sparse-seed-${process.pid}-`));
|
|
36
|
+
git(work, ['init']);
|
|
37
|
+
git(work, ['config', 'user.name', 'Test User']);
|
|
38
|
+
git(work, ['config', 'user.email', 'test@example.com']);
|
|
39
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
40
|
+
const abs = path.join(work, relPath);
|
|
41
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
42
|
+
fs.writeFileSync(abs, content);
|
|
43
|
+
}
|
|
44
|
+
git(work, ['add', '-A']);
|
|
45
|
+
git(work, ['commit', '-m', 'seed']);
|
|
46
|
+
git(work, ['branch', '-M', 'main']);
|
|
47
|
+
const head = git(work, ['rev-parse', 'HEAD']);
|
|
48
|
+
git(cwd, ['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
49
|
+
const pushTarget = `file://${path.resolve(cwd)}`;
|
|
50
|
+
git(work, ['remote', 'add', 'origin', pushTarget]);
|
|
51
|
+
git(work, ['push', 'origin', 'main']);
|
|
52
|
+
return { work, head };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function run() {
|
|
56
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `kb-team-sparse-${process.pid}-`));
|
|
57
|
+
const TEAM_REMOTE_SPARSE = path.join(tmpRoot, 'remote-sparse.git');
|
|
58
|
+
const LOCAL_CLONE = path.join(tmpRoot, 'local-clone');
|
|
59
|
+
const remoteUrl = `file://${TEAM_REMOTE_SPARSE.replace(/\\/g, '/')}`;
|
|
60
|
+
|
|
61
|
+
let scenariosPassed = 0;
|
|
62
|
+
let scenariosTotal = 0;
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------
|
|
65
|
+
// Scenario 1: New sparse clone
|
|
66
|
+
// ------------------------------------------------------------------
|
|
67
|
+
{
|
|
68
|
+
scenariosTotal++;
|
|
69
|
+
fs.rmSync(TEAM_REMOTE_SPARSE, { recursive: true, force: true });
|
|
70
|
+
fs.rmSync(LOCAL_CLONE, { recursive: true, force: true });
|
|
71
|
+
initBareRemote(TEAM_REMOTE_SPARSE);
|
|
72
|
+
seedRemoteWith(TEAM_REMOTE_SPARSE, {
|
|
73
|
+
'acc/README.md': '# acc\n',
|
|
74
|
+
'other/README.md': '# other\n',
|
|
75
|
+
'docs/notes.md': 'docs\n',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result = await githubTeamStore.checkoutStore({
|
|
79
|
+
cloneUrl: remoteUrl,
|
|
80
|
+
branch: 'main',
|
|
81
|
+
localPath: LOCAL_CLONE,
|
|
82
|
+
subdir: 'acc',
|
|
83
|
+
partialClone: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
assert(result.ok, `[scenario 1] checkoutStore should succeed: ${result.error || ''}`);
|
|
87
|
+
assert(result.action === 'cloned-partial', `[scenario 1] expected action='cloned-partial' but got '${result.action}'`);
|
|
88
|
+
assert(result.sparseCheckedOut === true, '[scenario 1] sparseCheckedOut should be true');
|
|
89
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'acc', 'README.md')), '[scenario 1] acc/README.md should exist');
|
|
90
|
+
assert(!fs.existsSync(path.join(LOCAL_CLONE, 'other', 'README.md')), '[scenario 1] other/README.md should NOT exist (cone excluded)');
|
|
91
|
+
assert(!fs.existsSync(path.join(LOCAL_CLONE, 'docs', 'notes.md')), '[scenario 1] docs/notes.md should NOT exist (cone excluded)');
|
|
92
|
+
const sparseFile = path.join(LOCAL_CLONE, '.git', 'info', 'sparse-checkout');
|
|
93
|
+
assert(fs.existsSync(sparseFile), '[scenario 1] .git/info/sparse-checkout should be present');
|
|
94
|
+
scenariosPassed++;
|
|
95
|
+
console.log('[scenario 1: NEW sparse clone] OK');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ------------------------------------------------------------------
|
|
99
|
+
// Scenario 2: Re-clone / add cone
|
|
100
|
+
// ------------------------------------------------------------------
|
|
101
|
+
{
|
|
102
|
+
scenariosTotal++;
|
|
103
|
+
fs.rmSync(TEAM_REMOTE_SPARSE, { recursive: true, force: true });
|
|
104
|
+
fs.rmSync(LOCAL_CLONE, { recursive: true, force: true });
|
|
105
|
+
initBareRemote(TEAM_REMOTE_SPARSE);
|
|
106
|
+
seedRemoteWith(TEAM_REMOTE_SPARSE, {
|
|
107
|
+
'acc/README.md': '# acc\n',
|
|
108
|
+
'other/README.md': '# other\n',
|
|
109
|
+
});
|
|
110
|
+
// First clone with subdir='acc'
|
|
111
|
+
const first = await githubTeamStore.checkoutStore({
|
|
112
|
+
cloneUrl: remoteUrl,
|
|
113
|
+
branch: 'main',
|
|
114
|
+
localPath: LOCAL_CLONE,
|
|
115
|
+
subdir: 'acc',
|
|
116
|
+
partialClone: true,
|
|
117
|
+
});
|
|
118
|
+
assert(first.ok && first.action === 'cloned-partial', `[scenario 2] first clone should be partial`);
|
|
119
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'acc', 'README.md')), '[scenario 2] acc/README.md should exist after first clone');
|
|
120
|
+
|
|
121
|
+
// Add a new subdir to remote
|
|
122
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), `kb-sparse-switch-${process.pid}-`));
|
|
123
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
124
|
+
fs.mkdirSync(work, { recursive: true });
|
|
125
|
+
git(work, ['clone', `file://${TEAM_REMOTE_SPARSE}`, '.']);
|
|
126
|
+
git(work, ['config', 'user.name', 'Test User']);
|
|
127
|
+
git(work, ['config', 'user.email', 'test@example.com']);
|
|
128
|
+
fs.mkdirSync(path.join(work, 'acc2'), { recursive: true });
|
|
129
|
+
fs.writeFileSync(path.join(work, 'acc2', 'README.md'), '# acc2\n');
|
|
130
|
+
git(work, ['add', '-A']);
|
|
131
|
+
git(work, ['commit', '-m', 'add acc2']);
|
|
132
|
+
git(work, ['push']);
|
|
133
|
+
|
|
134
|
+
// Re-sync with subdir='acc2' should expand the cone, not remove acc.
|
|
135
|
+
const second = await githubTeamStore.checkoutStore({
|
|
136
|
+
cloneUrl: remoteUrl,
|
|
137
|
+
branch: 'main',
|
|
138
|
+
localPath: LOCAL_CLONE,
|
|
139
|
+
subdir: 'acc2',
|
|
140
|
+
partialClone: true,
|
|
141
|
+
});
|
|
142
|
+
assert(second.ok, `[scenario 2] second checkout should succeed: ${second.error || ''}`);
|
|
143
|
+
assert(second.action === 'pulled-partial', `[scenario 2] expected action='pulled-partial' but got '${second.action}'`);
|
|
144
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'acc2', 'README.md')), '[scenario 2] acc2/README.md should exist after adding cone');
|
|
145
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'acc', 'README.md')), '[scenario 2] acc/README.md should still exist after adding acc2');
|
|
146
|
+
scenariosPassed++;
|
|
147
|
+
console.log('[scenario 2: cone expand] OK');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ------------------------------------------------------------------
|
|
151
|
+
// Scenario 3: Git version fallback
|
|
152
|
+
// ------------------------------------------------------------------
|
|
153
|
+
{
|
|
154
|
+
scenariosTotal++;
|
|
155
|
+
fs.rmSync(TEAM_REMOTE_SPARSE, { recursive: true, force: true });
|
|
156
|
+
fs.rmSync(LOCAL_CLONE, { recursive: true, force: true });
|
|
157
|
+
initBareRemote(TEAM_REMOTE_SPARSE);
|
|
158
|
+
seedRemoteWith(TEAM_REMOTE_SPARSE, {
|
|
159
|
+
'acc/README.md': '# acc\n',
|
|
160
|
+
'other/README.md': '# other\n',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Force the cached version to be < 2.25
|
|
164
|
+
gitRunner._setGitVersionForTests({ ok: true, major: 2, minor: 24, raw: 'git version 2.24.0' });
|
|
165
|
+
try {
|
|
166
|
+
const result = await githubTeamStore.checkoutStore({
|
|
167
|
+
cloneUrl: remoteUrl,
|
|
168
|
+
branch: 'main',
|
|
169
|
+
localPath: LOCAL_CLONE,
|
|
170
|
+
subdir: 'acc',
|
|
171
|
+
partialClone: true,
|
|
172
|
+
});
|
|
173
|
+
assert(result.ok, `[scenario 3] checkoutStore should fall back to full clone: ${result.error || ''}`);
|
|
174
|
+
assert(result.action === 'cloned-fallback', `[scenario 3] expected action='cloned-fallback' but got '${result.action}'`);
|
|
175
|
+
assert(typeof result.warning === 'string' && /2\.25/.test(result.warning), `[scenario 3] warning should mention 2.25: ${result.warning || ''}`);
|
|
176
|
+
// Full clone means both subdirs ARE present.
|
|
177
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'acc', 'README.md')), '[scenario 3] acc/README.md should exist (full clone)');
|
|
178
|
+
assert(fs.existsSync(path.join(LOCAL_CLONE, 'other', 'README.md')), '[scenario 3] other/README.md should exist (full clone)');
|
|
179
|
+
scenariosPassed++;
|
|
180
|
+
console.log('[scenario 3: Git < 2.25 fallback] OK');
|
|
181
|
+
} finally {
|
|
182
|
+
gitRunner._setGitVersionForTests(null);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`\nsummary: ${scenariosPassed}/${scenariosTotal} scenarios passed`);
|
|
187
|
+
if (scenariosPassed !== scenariosTotal) {
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
run().catch(err => {
|
|
193
|
+
console.error('test failed:', err.message);
|
|
194
|
+
console.error(err.stack);
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
7
|
+
const githubTeamStore = require('../lib/github-team-store');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
10
|
+
const PORT = Number(process.env.KB_TEAM_KNOWLEDGE_PORT || 7837);
|
|
11
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
12
|
+
const TEMP = fs.mkdtempSync(path.join(os.tmpdir(), `kb-team-v2-${process.pid}-`));
|
|
13
|
+
const DATA_DIR = path.join(TEMP, 'data');
|
|
14
|
+
const SOURCE_ONE = path.join(TEMP, 'source-one');
|
|
15
|
+
const SOURCE_TWO = path.join(TEMP, 'source-two');
|
|
16
|
+
const SOURCE_BAD = path.join(TEMP, 'source-bad');
|
|
17
|
+
const TEAM_STORE = path.join(TEMP, 'team-store');
|
|
18
|
+
const TEAM_REMOTE = path.join(TEMP, 'team-remote');
|
|
19
|
+
const TEAM_CLONE = path.join(TEMP, 'team-clone');
|
|
20
|
+
|
|
21
|
+
function git(cwd, args) {
|
|
22
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function initializeRepo(cwd, title) {
|
|
26
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
27
|
+
git(cwd, ['init', '--initial-branch=main']);
|
|
28
|
+
git(cwd, ['config', 'user.name', 'Team Test']);
|
|
29
|
+
git(cwd, ['config', 'user.email', 'team@example.test']);
|
|
30
|
+
fs.writeFileSync(path.join(cwd, 'README.md'), `# ${title}\n`, 'utf8');
|
|
31
|
+
git(cwd, ['add', '.']);
|
|
32
|
+
git(cwd, ['commit', '-m', 'baseline']);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function waitForServer() {
|
|
36
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
37
|
+
try { if ((await fetch(`${BASE_URL}/api/health`)).ok) return; } catch {}
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
39
|
+
}
|
|
40
|
+
throw new Error('server did not start');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function json(method, route, body) {
|
|
44
|
+
const response = await fetch(`${BASE_URL}${route}`, {
|
|
45
|
+
method,
|
|
46
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
47
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
48
|
+
});
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
return { response, body: text ? JSON.parse(text) : {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
(async () => {
|
|
54
|
+
let spawned;
|
|
55
|
+
try {
|
|
56
|
+
initializeRepo(SOURCE_ONE, 'Source one');
|
|
57
|
+
initializeRepo(SOURCE_TWO, 'Source two');
|
|
58
|
+
initializeRepo(SOURCE_BAD, 'Source bad');
|
|
59
|
+
initializeRepo(TEAM_STORE, 'Team store');
|
|
60
|
+
fs.mkdirSync(path.join(TEAM_STORE, 'acc'), { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(TEAM_STORE, 'acc', 'README.md'), '# ACC shared knowledge\n', 'utf8');
|
|
62
|
+
git(TEAM_STORE, ['add', '.']);
|
|
63
|
+
git(TEAM_STORE, ['commit', '-m', 'add team knowledge']);
|
|
64
|
+
git(TEAM_STORE, ['remote', 'add', 'origin', TEAM_STORE]);
|
|
65
|
+
|
|
66
|
+
initializeRepo(TEAM_REMOTE, 'Remote team store');
|
|
67
|
+
fs.mkdirSync(path.join(TEAM_REMOTE, 'acc'), { recursive: true });
|
|
68
|
+
fs.writeFileSync(path.join(TEAM_REMOTE, 'acc', 'README.md'), '# ACC from remote\n', 'utf8');
|
|
69
|
+
git(TEAM_REMOTE, ['add', '.']);
|
|
70
|
+
git(TEAM_REMOTE, ['commit', '-m', 'add remote knowledge']);
|
|
71
|
+
|
|
72
|
+
const providerConfigPath = path.join(TEMP, 'providers.json');
|
|
73
|
+
fs.writeFileSync(providerConfigPath, `\uFEFF${JSON.stringify({ gitea: { webBaseUrl: 'https://gitea.example.test', oauthClientId: 'client', oauthClientSecret: 'secret' } })}`, 'utf8');
|
|
74
|
+
const providerFile = githubTeamStore.readProviderFileConfig(providerConfigPath);
|
|
75
|
+
const publicProviders = githubTeamStore.providerPublicConfig(githubTeamStore.defaultConfig(), {}, providerFile);
|
|
76
|
+
assert.strictEqual(publicProviders.gitea.configured, true);
|
|
77
|
+
assert.strictEqual(publicProviders.gitea.oauthClientSecret, undefined);
|
|
78
|
+
assert.strictEqual(publicProviders.gitea.oauthClientSecretConfigured, true);
|
|
79
|
+
assert.strictEqual(githubTeamStore.proxyUrlForTarget('http://127.0.0.1:3000/api/v1/user', { KB_GIT_PROXY: 'http://127.0.0.1:7890' }), '');
|
|
80
|
+
|
|
81
|
+
const scanned = await githubTeamStore.scanLocalStore({ localPath: TEAM_STORE });
|
|
82
|
+
assert(scanned.ok && scanned.store.knowledgeBases.some(item => item.path === 'acc'), JSON.stringify(scanned));
|
|
83
|
+
const checkedOut = await githubTeamStore.checkoutStore({ cloneUrl: TEAM_REMOTE, branch: 'main', localPath: TEAM_CLONE });
|
|
84
|
+
assert(checkedOut.ok, JSON.stringify(checkedOut));
|
|
85
|
+
assert.match(fs.readFileSync(path.join(TEAM_CLONE, 'acc', 'README.md'), 'utf8'), /from remote/);
|
|
86
|
+
|
|
87
|
+
spawned = spawnServer({ root: ROOT, port: PORT, dataDir: DATA_DIR, tag: 'team-kb-v2' });
|
|
88
|
+
let output = '';
|
|
89
|
+
spawned.child.stdout.on('data', chunk => { output += chunk; });
|
|
90
|
+
spawned.child.stderr.on('data', chunk => { output += chunk; });
|
|
91
|
+
await waitForServer();
|
|
92
|
+
let result = await json('PATCH', '/api/settings', { knowledge: { rootPath: path.join(TEMP, 'personal-knowledge') } });
|
|
93
|
+
assert(result.response.ok, JSON.stringify(result.body));
|
|
94
|
+
|
|
95
|
+
result = await json('POST', '/api/projects/import', {
|
|
96
|
+
projectId: 'team-project-one', localPath: SOURCE_ONE,
|
|
97
|
+
teamKnowledgeBase: {
|
|
98
|
+
storeLocalPath: TEAM_STORE, storeRemoteUrl: 'https://github.com/org/knowledge.git',
|
|
99
|
+
storeFullName: 'org/knowledge', storeId: 'team-store', kbId: 'kb-acc', kbSlug: 'acc', kbSubdir: 'acc', displayName: 'ACC',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
assert.strictEqual(result.response.status, 201, `${JSON.stringify(result.body)}\n${output}`);
|
|
103
|
+
assert.strictEqual(path.resolve(result.body.config.knowledgePath), path.resolve(TEAM_STORE, 'acc'));
|
|
104
|
+
assert.strictEqual(result.body.config.teamBinding.provider, 'github');
|
|
105
|
+
assert.strictEqual(result.body.config.teamBinding.kbId, 'kb-acc');
|
|
106
|
+
assert.strictEqual(result.body.config.teamBinding.kbSubdir, 'acc');
|
|
107
|
+
const registryText = fs.readFileSync(path.join(DATA_DIR, 'projects.json'), 'utf8');
|
|
108
|
+
assert(!registryText.includes(TEAM_STORE), 'minimal v2 registry must not contain the external team path');
|
|
109
|
+
|
|
110
|
+
result = await json('POST', '/api/projects/import', {
|
|
111
|
+
projectId: 'team-project-bad', localPath: SOURCE_BAD,
|
|
112
|
+
teamKnowledgeBase: { storeLocalPath: TEAM_STORE, kbSubdir: '../escape' },
|
|
113
|
+
});
|
|
114
|
+
assert.strictEqual(result.response.status, 403, 'team binding traversal must be rejected');
|
|
115
|
+
|
|
116
|
+
result = await json('POST', '/api/projects/import', {
|
|
117
|
+
projectId: 'team-project-two', localPath: SOURCE_TWO,
|
|
118
|
+
teamKnowledgeBase: { storeLocalPath: TEAM_CLONE, storeRemoteUrl: TEAM_REMOTE, storeId: 'clone', kbId: 'kb-acc', kbSubdir: 'acc' },
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(result.response.status, 201, JSON.stringify(result.body));
|
|
121
|
+
assert.strictEqual(path.resolve(result.body.config.knowledgePath), path.resolve(TEAM_CLONE, 'acc'));
|
|
122
|
+
|
|
123
|
+
result = await json('DELETE', '/api/projects/team-project-one', { deleteKnowledge: true, confirmationToken: 'team-project-one' });
|
|
124
|
+
assert(result.response.ok, JSON.stringify(result.body));
|
|
125
|
+
assert.strictEqual(result.body.removedKnowledge, false, 'team knowledge is externally owned');
|
|
126
|
+
assert(fs.existsSync(path.join(TEAM_STORE, 'acc', 'README.md')), 'team knowledge must survive project deletion');
|
|
127
|
+
console.log('team knowledge store test passed');
|
|
128
|
+
} finally {
|
|
129
|
+
if (spawned) spawned.child.kill();
|
|
130
|
+
}
|
|
131
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Run: node _site/_test/tracking-start-test.js
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
9
|
+
const { ProjectRegistryStore } = require('../lib/project-registry-store');
|
|
10
|
+
const { ProjectStore } = require('../lib/project-store');
|
|
11
|
+
const { ProjectLifecycleService } = require('../lib/project-lifecycle-service');
|
|
12
|
+
const { CommitReconciler } = require('../lib/commit-reconciler');
|
|
13
|
+
|
|
14
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `kb-tracking-start-${process.pid}-`));
|
|
15
|
+
const layout = new StorageLayout({ dataDir: path.join(temp, 'data') });
|
|
16
|
+
const registry = new ProjectRegistryStore({ layout });
|
|
17
|
+
const projects = new ProjectStore({ layout });
|
|
18
|
+
const knowledgeRoot = path.join(temp, 'knowledge');
|
|
19
|
+
|
|
20
|
+
function git(repo, args) {
|
|
21
|
+
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', windowsHide: true });
|
|
22
|
+
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed`);
|
|
23
|
+
return String(result.stdout || '').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function repo(name, withCommit) {
|
|
27
|
+
const target = path.join(temp, name);
|
|
28
|
+
fs.mkdirSync(target, { recursive: true });
|
|
29
|
+
git(target, ['init', '--initial-branch=main']);
|
|
30
|
+
git(target, ['config', 'user.email', 'tracking@example.test']);
|
|
31
|
+
git(target, ['config', 'user.name', 'Tracking Test']);
|
|
32
|
+
if (withCommit) commit(target, 'imported');
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commit(target, name) {
|
|
37
|
+
fs.writeFileSync(path.join(target, `${name}.txt`), `${name}\n`, 'utf8');
|
|
38
|
+
git(target, ['add', `${name}.txt`]);
|
|
39
|
+
git(target, ['commit', '-m', name]);
|
|
40
|
+
return git(target, ['rev-parse', 'HEAD']);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
(async () => {
|
|
44
|
+
fs.mkdirSync(knowledgeRoot, { recursive: true });
|
|
45
|
+
await registry.initialize();
|
|
46
|
+
const installed = [];
|
|
47
|
+
const lifecycle = new ProjectLifecycleService({
|
|
48
|
+
layout,
|
|
49
|
+
registryStore: registry,
|
|
50
|
+
projectStore: projects,
|
|
51
|
+
settingsStore: { read: () => ({ knowledge: { rootPath: knowledgeRoot }, ai: { schema: 'ai-profiles/v1', profiles: [{ id: 'test-profile', enabled: true, vendor: 'anthropic', model: 'm' }], defaultProfileId: null } }) },
|
|
52
|
+
triggerScriptPath: path.join(temp, 'hook-trigger.js'),
|
|
53
|
+
hookManager: {
|
|
54
|
+
installHook: options => { installed.push(options.projectId); return { ok: true, managedVersion: 2 }; },
|
|
55
|
+
uninstallHook: () => ({ ok: true }),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const existingRepo = repo('existing-repo', true);
|
|
60
|
+
const importedHead = git(existingRepo, ['rev-parse', 'HEAD']);
|
|
61
|
+
const imported = await lifecycle.importProject({ localPath: existingRepo, projectId: 'project-existing', aiProfileId: 'test-profile' });
|
|
62
|
+
assert.strictEqual(imported.state.trackingStartCommit, importedHead);
|
|
63
|
+
assert.strictEqual(imported.state.lastAnalyzedCommit, null);
|
|
64
|
+
assert.strictEqual(imported.state.trackingMode, 'normal');
|
|
65
|
+
assert.strictEqual(installed.length, 1, 'import should install Hook but not analyze');
|
|
66
|
+
|
|
67
|
+
let processorCalls = 0;
|
|
68
|
+
const processor = {
|
|
69
|
+
processClaim: async input => {
|
|
70
|
+
processorCalls += 1;
|
|
71
|
+
await projects.updateState(input.projectId, state => {
|
|
72
|
+
state.lastAnalyzedCommit = input.claim.commitSha;
|
|
73
|
+
state.analysis.activeClaim = null;
|
|
74
|
+
state.analysis.status = 'state.advanced';
|
|
75
|
+
state.index.dirty = true;
|
|
76
|
+
state.index.generation += 1;
|
|
77
|
+
});
|
|
78
|
+
return { stateAdvanced: true };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const reconciler = new CommitReconciler({
|
|
82
|
+
layout,
|
|
83
|
+
registryStore: registry,
|
|
84
|
+
projectStore: projects,
|
|
85
|
+
settingsStore: { read: () => ({ knowledge: { rootPath: knowledgeRoot }, ai: { schema: 'ai-profiles/v1', profiles: [{ id: 'test-profile', enabled: true, vendor: 'anthropic', model: 'm' }], defaultProfileId: null } }) },
|
|
86
|
+
claimProcessor: processor,
|
|
87
|
+
});
|
|
88
|
+
await assert.rejects(reconciler.reconcile('project-existing', 'startup'), error => error.code === 'INVALID_TRIGGER');
|
|
89
|
+
assert.strictEqual(processorCalls, 0, 'startup after import must not analyze pre-import history');
|
|
90
|
+
const nextCommit = commit(existingRepo, 'after-import');
|
|
91
|
+
const laterCommit = commit(existingRepo, 'after-import-later');
|
|
92
|
+
await reconciler.processCommitEvent({ projectId: 'project-existing', commitSha: nextCommit, branch: 'main' });
|
|
93
|
+
assert.strictEqual(projects.readState('project-existing').lastAnalyzedCommit, nextCommit);
|
|
94
|
+
assert.strictEqual(processorCalls, 1);
|
|
95
|
+
await reconciler.processCommitEvent({ projectId: 'project-existing', commitSha: laterCommit, branch: 'main' });
|
|
96
|
+
assert.strictEqual(projects.readState('project-existing').lastAnalyzedCommit, laterCommit, 'an event SHA remains valid even after HEAD advances');
|
|
97
|
+
assert.strictEqual(processorCalls, 2);
|
|
98
|
+
|
|
99
|
+
const terminalFailedCommit = 'a'.repeat(40);
|
|
100
|
+
await projects.updateState('project-existing', state => {
|
|
101
|
+
state.analysis.activeClaim = {
|
|
102
|
+
schema: 'commit-claim/v1', projectId: 'project-existing', commitSha: terminalFailedCommit, parents: [], requirementIds: [], requirementBinding: 'unavailable',
|
|
103
|
+
promptTemplateVersion: 'v1', promptHash: 'prompt', patchHash: 'patch', evidenceHash: 'evidence', evidenceManifestHash: 'manifest', retrievalManifestHash: 'retrieval', knowledgePath: knowledgeRoot,
|
|
104
|
+
runId: 'failed-run', operationId: 'failed-operation', phase: 'failed', attempt: 1, fingerprint: 'failed-fingerprint', error: { code: 'EPERM', retryable: false },
|
|
105
|
+
};
|
|
106
|
+
state.analysis.status = 'failed';
|
|
107
|
+
});
|
|
108
|
+
const explicitAfterFailure = commit(existingRepo, 'after-terminal-failure');
|
|
109
|
+
await reconciler.processCommitEvent({ projectId: 'project-existing', commitSha: explicitAfterFailure, branch: 'main' });
|
|
110
|
+
assert.strictEqual(projects.readState('project-existing').lastAnalyzedCommit, explicitAfterFailure, 'only the new explicit Hook SHA may proceed after a terminal failure');
|
|
111
|
+
assert.strictEqual(processorCalls, 3, 'terminal failed claims must not retry their old SHA');
|
|
112
|
+
|
|
113
|
+
const retryableFailedCommit = 'b'.repeat(40);
|
|
114
|
+
await projects.updateState('project-existing', state => {
|
|
115
|
+
state.analysis.activeClaim = {
|
|
116
|
+
schema: 'commit-claim/v1', projectId: 'project-existing', commitSha: retryableFailedCommit, parents: [], requirementIds: [], requirementBinding: 'unavailable',
|
|
117
|
+
promptTemplateVersion: 'v1', promptHash: 'prompt', patchHash: 'patch', evidenceHash: 'evidence', evidenceManifestHash: 'manifest', retrievalManifestHash: 'retrieval', knowledgePath: knowledgeRoot,
|
|
118
|
+
runId: 'retryable-run', operationId: 'retryable-operation', phase: 'failed', attempt: 1, fingerprint: 'retryable-fingerprint', error: { code: 'EAGAIN', retryable: true },
|
|
119
|
+
};
|
|
120
|
+
state.analysis.status = 'failed';
|
|
121
|
+
});
|
|
122
|
+
const blockedByRetryable = commit(existingRepo, 'after-retryable-failure');
|
|
123
|
+
const blockedResult = await reconciler.processCommitEvent({ projectId: 'project-existing', commitSha: blockedByRetryable, branch: 'main' });
|
|
124
|
+
assert.strictEqual(blockedResult.status, 'failed');
|
|
125
|
+
assert.strictEqual(processorCalls, 3, 'retryable claims must remain blocked instead of being silently superseded');
|
|
126
|
+
assert.strictEqual(projects.readState('project-existing').analysis.activeClaim.commitSha, retryableFailedCommit);
|
|
127
|
+
|
|
128
|
+
const emptyRepo = repo('empty-repo', false);
|
|
129
|
+
const emptyImported = await lifecycle.importProject({ localPath: emptyRepo, projectId: 'project-empty', aiProfileId: 'test-profile' });
|
|
130
|
+
assert.strictEqual(emptyImported.state.trackingMode, 'empty-repo');
|
|
131
|
+
assert.strictEqual(emptyImported.state.trackingStartCommit, null);
|
|
132
|
+
const firstCommit = commit(emptyRepo, 'first');
|
|
133
|
+
await reconciler.processCommitEvent({ projectId: 'project-empty', commitSha: firstCommit, branch: 'main' });
|
|
134
|
+
assert.strictEqual(projects.readState('project-empty').lastAnalyzedCommit, firstCommit, 'first commit after empty import must be analyzed');
|
|
135
|
+
assert.strictEqual(processorCalls, 4);
|
|
136
|
+
|
|
137
|
+
console.log('tracking-start-test PASS');
|
|
138
|
+
})().catch(error => {
|
|
139
|
+
console.error(error.stack || error.message);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}).finally(() => {
|
|
142
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
143
|
+
});
|