Files
memind/scheduled-task-executor.test.mjs
T
john 028bb18dc0 feat(news): add single news-item page generation and early page-ready wait
Scheduled tasks can finish once the expected HTML is on disk and stable,
instead of waiting for Goose Finish. Adds news-item templates and scripts
for generating a single-event page and committing it as a WeChat draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 10:07:32 +08:00

356 lines
12 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 {
buildScheduledTaskExecutionPrompt,
buildScheduledTaskVerifiedHtmlUrls,
buildSimpleScheduledTaskReminderDelivery,
deliveryTextPromisesPublicHtml,
extractPublicHtmlPathsFromText,
extractScheduledTaskDeliveryText,
finalizeScheduledTaskPageDelivery,
formatScheduledTaskDeliveryMessage,
isSimpleScheduledTaskReminder,
looksLikeScheduledTaskNonDelivery,
reconcileStuckStaticPageDeliveryContracts,
resendScheduledTaskWechatForReadyPage,
resolveScheduledTaskDeliveryPollIntervalMs,
resolveScheduledTaskDeliveryRetryDelaysMs,
waitForExpectedPageFile,
} from './scheduled-task-executor.mjs';
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
const message = buildScheduledTaskExecutionPrompt({
id: 'task-1',
title: '每日新闻页',
taskSpec: '搜索今日新闻并生成 HTML 页面',
recurrence: 'daily',
timezone: 'Asia/Shanghai',
});
const text = message.content[0].text;
assert.match(text, /scheduled-task-automation/);
assert.match(text, /每日新闻页/);
assert.match(text, /搜索今日新闻并生成 HTML 页面/);
assert.match(text, /Scheduled Automation/);
assert.match(text, /禁止调用 scheduled_task_create/);
});
test('isSimpleScheduledTaskReminder matches text-only reminder taskSpec', () => {
assert.equal(
isSimpleScheduledTaskReminder({
taskSpec: '提醒 icyxu:该吃药了,请按时服药,注意身体健康。',
}),
true,
);
assert.equal(
isSimpleScheduledTaskReminder({
taskSpec: '搜索今日新闻并生成 HTML 页面',
}),
false,
);
});
test('buildSimpleScheduledTaskReminderDelivery formats reminder body', () => {
assert.equal(
buildSimpleScheduledTaskReminderDelivery({
taskSpec: '提醒 icyxu:该吃药了,请按时服药,注意身体健康。',
}),
'icyxu,该吃药了,请按时服药,注意身体健康。',
);
});
test('looksLikeScheduledTaskNonDelivery detects task setup replies', () => {
assert.equal(
looksLikeScheduledTaskNonDelivery('定时自动任务已成功设置 ✅\n| 任务标题 | 每天上午10点提醒吃药 |'),
true,
);
assert.equal(
looksLikeScheduledTaskNonDelivery('任务已经存在且处于 active 状态,无需重复创建。'),
true,
);
});
test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
assert.equal(
looksLikeScheduledTaskNonDelivery('请问 7月31日具体几点执行这个任务?'),
true,
);
assert.equal(
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
false,
);
assert.equal(
looksLikeScheduledTaskNonDelivery(
'页面已生成:https://m.tkmind.cn/MindSpace/user-1/public/daily-news.html',
),
true,
);
assert.equal(
looksLikeScheduledTaskNonDelivery('好的', { readyPaths: ['public/news.html'] }),
false,
);
assert.equal(looksLikeScheduledTaskNonDelivery('验证成功'), false);
assert.equal(looksLikeScheduledTaskNonDelivery('已完成'), false);
assert.equal(looksLikeScheduledTaskNonDelivery('好的'), true);
});
test('extractPublicHtmlPathsFromText parses MindSpace public links', () => {
assert.deepEqual(
extractPublicHtmlPathsFromText(
'链接:https://m.tkmind.cn/MindSpace/u/public/daily-news-0816.html',
),
['public/daily-news-0816.html'],
);
});
test('deliveryTextPromisesPublicHtml detects page delivery replies', () => {
assert.equal(
deliveryTextPromisesPublicHtml('今日摘要:天气不错'),
false,
);
assert.equal(
deliveryTextPromisesPublicHtml('public/daily-news.html 已生成'),
true,
);
});
test('resolveScheduledTaskDeliveryPollIntervalMs reads env override', () => {
assert.equal(
resolveScheduledTaskDeliveryPollIntervalMs({
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '7500',
}),
7500,
);
});
test('resolveScheduledTaskDeliveryRetryDelaysMs keeps backward-compatible shape', () => {
assert.deepEqual(
resolveScheduledTaskDeliveryRetryDelaysMs({
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '100',
}),
[0, 100, 100, 100],
);
});
test('buildScheduledTaskVerifiedHtmlUrls maps ready paths to public urls', () => {
assert.deepEqual(
buildScheduledTaskVerifiedHtmlUrls('user-1', ['public/news.html'], {
publicBaseUrl: 'https://m.tkmind.cn',
}),
['https://m.tkmind.cn/MindSpace/user-1/public/news.html'],
);
});
test('finalizeScheduledTaskPageDelivery prepares and releases static contracts', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-'));
const publishDir = path.join(dir, 'MindSpace', 'user-1');
const relativePath = 'public/news.html';
await fs.mkdir(path.dirname(path.join(publishDir, relativePath)), { recursive: true });
await fs.writeFile(path.join(publishDir, relativePath), '<html></html>', 'utf8');
const calls = [];
const pool = {
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ workspace_relative_path: relativePath }]];
}
if (sql.includes('SELECT id, data_mode, status')) {
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
}
if (sql.includes("SET status = 'ready'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId: 'user-1',
sessionId: 'session-1',
messages: [],
publishDir,
deliveryText: 'public/news.html 已生成',
logger: { warn() {}, info() {} },
});
assert.deepEqual(readyPaths, [relativePath]);
assert.ok(calls.some((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts')));
});
test('finalizeScheduledTaskPageDelivery skips release when html file is missing', async () => {
const publishDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-missing-')), 'MindSpace', 'user-1');
await fs.mkdir(publishDir, { recursive: true });
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ workspace_relative_path: 'public/news.html' }]];
}
if (sql.includes('SELECT id, data_mode, status')) {
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
}
if (sql.includes("SET status = 'ready'")) {
throw new Error('should not mark ready without html file');
}
return [[]];
},
};
const readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId: 'user-1',
sessionId: 'session-1',
messages: [],
publishDir,
deliveryText: 'public/news.html 已生成',
logger: { warn() {}, info() {} },
});
assert.deepEqual(readyPaths, []);
});
test('reconcileStuckStaticPageDeliveryContracts releases materialized static pages', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-'));
const userId = 'user-1';
const relativePath = 'public/news.html';
const filePath = path.join(dir, 'MindSpace', userId, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '<html></html>', 'utf8');
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
}
if (sql.includes("SET status = 'ready'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const released = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root: dir,
logger: { info() {} },
});
assert.deepEqual(released, [{ userId, relativePath }]);
});
test('reconcileStuckStaticPageDeliveryContracts fails orphan contracts without html', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-orphan-'));
const userId = 'user-1';
const relativePath = 'public/missing.html';
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
}
if (sql.includes("SET status = 'failed'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const released = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root: dir,
logger: { info() {} },
});
assert.deepEqual(released, []);
});
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
const text = extractScheduledTaskDeliveryText([
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', content: [{ type: 'text', text: '今日新闻页:https://example.com/news.html' }] },
], { title: '每日新闻页' });
assert.match(text, /https:\/\/example.com\/news.html/);
});
test('formatScheduledTaskDeliveryMessage wraps delivery body', () => {
const text = formatScheduledTaskDeliveryMessage(
{ title: '每日新闻页' },
'页面已生成:https://example.com/news.html',
);
assert.match(text, /定时任务完成:每日新闻页/);
assert.match(text, /页面已生成/);
});
test('resendScheduledTaskWechatForReadyPage skips suspended users', async () => {
let sendCount = 0;
const sent = await resendScheduledTaskWechatForReadyPage({
pool: {
async query(sql) {
if (sql.includes('SELECT status FROM h5_users')) {
return [[{ status: 'suspended' }]];
}
throw new Error(`unexpected query: ${sql}`);
},
},
userId: 'user-suspended',
relativePath: 'public/news.html',
notificationDispatcher: {
async sendScheduleNotification() {
sendCount += 1;
return true;
},
},
logger: { info() {}, warn() {} },
});
assert.equal(sent, false);
assert.equal(sendCount, 0);
});
test('waitForExpectedPageFile resolves once html file size stabilizes', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-page-'));
const publishDir = path.join(root, 'MindSpace', 'user-1');
const relativePath = 'public/news-item-demo.html';
const filePath = path.join(publishDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
let polls = 0;
const pending = waitForExpectedPageFile({
publishDir,
relativePath,
pollIntervalMs: 20,
stablePolls: 2,
minBytes: 10,
sleepFn: (delayMs) => new Promise((resolve) => {
polls += 1;
setTimeout(async () => {
if (polls === 2) {
await fs.writeFile(filePath, '<html>draft</html>', 'utf8');
}
resolve(delayMs);
}, 0);
}),
});
assert.equal(await pending, relativePath);
});
test('waitForExpectedPageFile waits until isPageReady passes', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-page-ready-'));
const publishDir = path.join(root, 'MindSpace', 'user-2');
const relativePath = 'public/news-item-ready.html';
const filePath = path.join(publishDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
let polls = 0;
const pending = waitForExpectedPageFile({
publishDir,
relativePath,
pollIntervalMs: 20,
stablePolls: 2,
minBytes: 10,
isPageReady: (html) => html.includes('DONE'),
sleepFn: (delayMs) => new Promise((resolve) => {
polls += 1;
setTimeout(async () => {
const body = polls >= 4 ? '<html>DONE</html>' : '<html>draft</html>';
await fs.writeFile(filePath, body, 'utf8');
resolve(delayMs);
}, 0);
}),
});
assert.equal(await pending, relativePath);
});