028bb18dc0
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>
356 lines
12 KiB
JavaScript
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);
|
|
});
|