7f8d692d16
Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
15 KiB
JavaScript
424 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
createWorkspaceAssetSync,
|
|
listWorkspaceZoneFiles,
|
|
shouldSyncWorkspaceFilename,
|
|
} from './mindspace-workspace-sync.mjs';
|
|
import { resolveUserWorkspaceRoot } from './user-space.mjs';
|
|
|
|
test('shouldSyncWorkspaceFilename skips helper files', () => {
|
|
assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true);
|
|
assert.equal(shouldSyncWorkspaceFilename('暑假实习报告/暑假生活实习报告.docx'), true);
|
|
assert.equal(shouldSyncWorkspaceFilename('诗歌散文/夏日的诗篇.md'), true);
|
|
assert.equal(shouldSyncWorkspaceFilename('sales-manager/README.md'), false);
|
|
assert.equal(shouldSyncWorkspaceFilename('index.html'), false);
|
|
assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false);
|
|
assert.equal(shouldSyncWorkspaceFilename('../escape.docx'), false);
|
|
});
|
|
|
|
test('listWorkspaceZoneFiles returns supported top-level and nested zone files', async () => {
|
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-'));
|
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
|
await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true });
|
|
await fs.mkdir(path.join(workspace, 'oa', '诗歌散文'), { recursive: true });
|
|
await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n');
|
|
await fs.writeFile(path.join(workspace, 'oa', 'index.html'), '<html></html>');
|
|
await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello');
|
|
await fs.writeFile(path.join(workspace, 'oa', '诗歌散文', '夏日的诗篇.md'), '# 夏日的诗篇\n');
|
|
await fs.writeFile(
|
|
path.join(workspace, 'oa', '暑假实习报告', '暑假生活实习报告.docx'),
|
|
'fake docx',
|
|
);
|
|
|
|
const files = await listWorkspaceZoneFiles(workspace, 'oa');
|
|
assert.deepEqual(
|
|
files.map((item) => item.filename).sort(),
|
|
['note.txt', 'report.csv', '暑假实习报告/暑假生活实习报告.docx', '诗歌散文/夏日的诗篇.md'].sort(),
|
|
);
|
|
});
|
|
|
|
test('syncUserWorkspace imports new workspace files into asset library', async () => {
|
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-'));
|
|
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
|
await fs.mkdir(path.join(workspace, 'oa'), { recursive: true });
|
|
await fs.writeFile(path.join(workspace, 'oa', 'memo.txt'), 'hello workspace\n');
|
|
|
|
const state = {
|
|
users: [{ id: 'user-1', username: 'john' }],
|
|
categories: [
|
|
{ id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' },
|
|
],
|
|
spaces: [
|
|
{
|
|
id: 'space-1',
|
|
user_id: 'user-1',
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 0,
|
|
reserved_bytes: 0,
|
|
status: 'active',
|
|
},
|
|
],
|
|
assets: [],
|
|
versions: [],
|
|
};
|
|
|
|
let nextId = 0;
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('SELECT username FROM h5_users')) {
|
|
return [[{ username: 'john' }]];
|
|
}
|
|
if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) {
|
|
const category = state.categories.find(
|
|
(item) => item.user_id === params[0] && item.category_code === params[1],
|
|
);
|
|
return [category ? [category] : []];
|
|
}
|
|
if (sql.includes('FROM h5_assets')) {
|
|
const assets = state.assets.filter((item) => {
|
|
if (item.user_id !== params[0] || item.category_id !== params[1]) return false;
|
|
if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false;
|
|
if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false;
|
|
if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return [assets];
|
|
}
|
|
return [[]];
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
|
const space = state.spaces.find(
|
|
(item) => item.id === params[0] && item.user_id === params[1],
|
|
);
|
|
return [space ? [space] : []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_assets')) {
|
|
state.assets.push({
|
|
id: params[0],
|
|
user_id: params[1],
|
|
category_id: params[3],
|
|
original_filename: params[6],
|
|
checksum: params[11],
|
|
size_bytes: params[10],
|
|
current_version_id: params[9],
|
|
risk_level: params[12],
|
|
visibility: params[13],
|
|
status: params[14],
|
|
source_type: 'workspace',
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_asset_versions')) {
|
|
state.versions.push({
|
|
id: params[0],
|
|
asset_id: params[1],
|
|
storage_key: params[2],
|
|
scan_status: params[7],
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('used_bytes = used_bytes +')) {
|
|
state.spaces[0].used_bytes += params[0];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('COALESCE(MAX(version_no)')) {
|
|
return [[{ max_version: 0 }]];
|
|
}
|
|
return pool.query(sql, params);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const packageCalls = [];
|
|
const conversationPackageRegistry = {
|
|
async ensurePackage(input) {
|
|
packageCalls.push(['ensurePackage', input]);
|
|
return { id: 'cp-session-1' };
|
|
},
|
|
async recordArtifact(input) {
|
|
packageCalls.push(['recordArtifact', input]);
|
|
return input;
|
|
},
|
|
async writeManifestForSession(input) {
|
|
packageCalls.push(['writeManifestForSession', input]);
|
|
return input;
|
|
},
|
|
};
|
|
const sync = createWorkspaceAssetSync({
|
|
pool,
|
|
storageRoot,
|
|
h5Root,
|
|
maxFileBytes: 1024 * 1024,
|
|
idFactory: () => `id-${++nextId}`,
|
|
conversationPackageRegistry,
|
|
});
|
|
|
|
const result = await sync.syncUserWorkspace('user-1', {
|
|
categoryCode: 'oa',
|
|
sourceSessionId: 'session-1',
|
|
sourceMessageId: 'message-1',
|
|
title: 'Workspace chat',
|
|
});
|
|
assert.equal(result.imported, 1);
|
|
assert.equal(state.assets.length, 1);
|
|
assert.equal(state.assets[0].original_filename, 'memo.txt');
|
|
assert.ok(state.versions.length === 1);
|
|
assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/);
|
|
assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n');
|
|
assert.deepEqual(packageCalls[0], [
|
|
'ensurePackage',
|
|
{
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
title: 'Workspace chat',
|
|
now: packageCalls[0][1].now,
|
|
},
|
|
]);
|
|
const artifactCall = packageCalls.find(([name]) => name === 'recordArtifact')?.[1];
|
|
assert.equal(artifactCall.packageId, 'cp-session-1');
|
|
assert.equal(artifactCall.artifactKind, 'generated_file');
|
|
assert.equal(artifactCall.role, 'assistant');
|
|
assert.equal(artifactCall.assetId, 'id-1');
|
|
assert.equal(artifactCall.messageId, 'message-1');
|
|
assert.equal(artifactCall.displayName, 'memo.txt');
|
|
assert.equal(artifactCall.mimeType, 'text/plain');
|
|
assert.equal(artifactCall.storageKey, 'workspace://user-1/oa/memo.txt');
|
|
assert.equal(artifactCall.canonicalUrl, '/api/mindspace/v1/assets/id-1/download');
|
|
assert.deepEqual(packageCalls.at(-1), [
|
|
'writeManifestForSession',
|
|
{ userId: 'user-1', sessionId: 'session-1' },
|
|
]);
|
|
});
|
|
|
|
test('syncUserWorkspace imports sandboxable public html as warned ready asset', async () => {
|
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-public-html-'));
|
|
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
|
await fs.mkdir(path.join(workspace, 'public'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(workspace, 'public', 'dashboard.html'),
|
|
`<!doctype html>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
<script>new Chart(document.createElement('canvas'), { type: 'bar' });</script>`,
|
|
);
|
|
|
|
const state = {
|
|
users: [{ id: 'user-1', username: 'john' }],
|
|
categories: [
|
|
{ id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' },
|
|
],
|
|
spaces: [
|
|
{
|
|
id: 'space-1',
|
|
user_id: 'user-1',
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 0,
|
|
reserved_bytes: 0,
|
|
status: 'active',
|
|
},
|
|
],
|
|
assets: [],
|
|
versions: [],
|
|
};
|
|
|
|
let nextId = 0;
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('SELECT username FROM h5_users')) {
|
|
return [[{ username: 'john' }]];
|
|
}
|
|
if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) {
|
|
const category = state.categories.find(
|
|
(item) => item.user_id === params[0] && item.category_code === params[1],
|
|
);
|
|
return [category ? [category] : []];
|
|
}
|
|
if (sql.includes('FROM h5_assets')) return [[]];
|
|
return [[]];
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
|
return [[state.spaces[0]]];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_assets')) {
|
|
state.assets.push({
|
|
id: params[0],
|
|
original_filename: params[6],
|
|
workspace_relative_path: params[8],
|
|
size_bytes: params[10],
|
|
checksum: params[11],
|
|
risk_level: params[12],
|
|
visibility: params[13],
|
|
status: params[14],
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_asset_versions')) {
|
|
state.versions.push({
|
|
id: params[0],
|
|
asset_id: params[1],
|
|
storage_key: params[2],
|
|
scan_status: params[7],
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('used_bytes = used_bytes +')) {
|
|
state.spaces[0].used_bytes += params[0];
|
|
return [[]];
|
|
}
|
|
return pool.query(sql, params);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const sync = createWorkspaceAssetSync({
|
|
pool,
|
|
storageRoot,
|
|
h5Root,
|
|
maxFileBytes: 1024 * 1024,
|
|
idFactory: () => `id-${++nextId}`,
|
|
});
|
|
|
|
const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'public' });
|
|
assert.equal(result.imported, 1);
|
|
assert.equal(state.assets[0].status, 'ready');
|
|
assert.equal(state.assets[0].risk_level, 'medium');
|
|
assert.equal(state.assets[0].visibility, 'public_candidate');
|
|
assert.equal(state.versions[0].scan_status, 'warned');
|
|
assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/public\/dashboard\.html$/);
|
|
});
|
|
|
|
test('syncUserWorkspace imports nested workspace files into asset library', async () => {
|
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-'));
|
|
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
|
await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true });
|
|
await fs.writeFile(path.join(workspace, 'oa', '暑假实习报告', 'report.csv'), 'a,b\n1,2\n');
|
|
|
|
const state = {
|
|
categories: [
|
|
{ id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' },
|
|
],
|
|
spaces: [
|
|
{
|
|
id: 'space-1',
|
|
user_id: 'user-1',
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 0,
|
|
reserved_bytes: 0,
|
|
status: 'active',
|
|
},
|
|
],
|
|
assets: [],
|
|
versions: [],
|
|
};
|
|
|
|
let nextId = 0;
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) {
|
|
const category = state.categories.find(
|
|
(item) => item.user_id === params[0] && item.category_code === params[1],
|
|
);
|
|
return [category ? [category] : []];
|
|
}
|
|
if (sql.includes('FROM h5_assets')) {
|
|
const assets = state.assets.filter((item) => {
|
|
if (item.user_id !== params[0] || item.category_id !== params[1]) return false;
|
|
if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false;
|
|
if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false;
|
|
if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return [assets];
|
|
}
|
|
return [[]];
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
|
const space = state.spaces.find(
|
|
(item) => item.id === params[0] && item.user_id === params[1],
|
|
);
|
|
return [space ? [space] : []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_assets')) {
|
|
state.assets.push({
|
|
id: params[0],
|
|
user_id: params[1],
|
|
category_id: params[3],
|
|
original_filename: params[6],
|
|
checksum: params[11],
|
|
size_bytes: params[10],
|
|
current_version_id: params[9],
|
|
risk_level: params[12],
|
|
visibility: params[13],
|
|
status: params[14],
|
|
source_type: 'workspace',
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_asset_versions')) {
|
|
state.versions.push({
|
|
id: params[0],
|
|
asset_id: params[1],
|
|
storage_key: params[2],
|
|
scan_status: params[7],
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('used_bytes = used_bytes +')) {
|
|
state.spaces[0].used_bytes += params[0];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('COALESCE(MAX(version_no)')) {
|
|
return [[{ max_version: 0 }]];
|
|
}
|
|
return pool.query(sql, params);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const sync = createWorkspaceAssetSync({
|
|
pool,
|
|
storageRoot,
|
|
h5Root,
|
|
maxFileBytes: 1024 * 1024,
|
|
idFactory: () => `id-${++nextId}`,
|
|
});
|
|
|
|
const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' });
|
|
assert.equal(result.imported, 1);
|
|
assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv');
|
|
});
|