zames_pro 2.75.3 → 2.75.5

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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.75.5] - 2026-10-15
11
+
12
+ ### Fixed
13
+
14
+ - **i18n:** clearer TUI key hints; localize the scrolled status; add sched-smoke
15
+
16
+ ## [2.75.4] - 2026-10-15
17
+
18
+ ### Added
19
+
20
+ - **qwen:** per-model context window for the status bar and auto-compact
21
+
22
+ ### Changed
23
+
24
+ - **test:** format qwen-context test with prettier
25
+
10
26
  ## [2.75.3] - 2026-10-15
11
27
 
12
28
  ### Fixed
package/README.md CHANGED
@@ -259,8 +259,8 @@ Full-screen mode adds dialog navigation, so a long answer can be read and
259
259
  selected without leaving the terminal:
260
260
 
261
261
  - mouse wheel — scroll the dialog (never the input history; history is arrow
262
- keys only). Selecting text needs `Shift`+drag, the standard TUI convention.
263
- - `PgUp` / `PgDn` — scroll the dialog half a screen.
262
+ keys only). Selecting text needs `Shift`+click, the standard TUI convention.
263
+ - `PgUp` / `PgDn` — move up/down the dialog half a screen.
264
264
  - `Home` / `End` — jump to the very top / bottom of the dialog (only while the
265
265
  input line is empty; with text typed they move the caret as usual).
266
266
  - To copy a LARGE block, use `/export` (writes the whole session as Markdown)
@@ -839,8 +839,8 @@ zames
839
839
  можно было прочитать и выделить не покидая терминал:
840
840
 
841
841
  - колесо мыши — скролл диалога (историю ввода колесо НЕ листает; история —
842
- только стрелки). Выделение текста — `Shift`+протяжка, стандарт TUI.
843
- - `PgUp` / `PgDn` — листать диалог на пол-экрана.
842
+ только стрелки). Выделение текста — `Shift`+клик, стандарт TUI.
843
+ - `PgUp` / `PgDn` — вверх/вниз по диалогу на пол-экрана.
844
844
  - `Home` / `End` — прыжок в самый верх / низ диалога (только когда строка ввода
845
845
  пуста; при набранном тексте они двигают курсор, как обычно).
846
846
  - Чтобы скопировать БОЛЬШОЙ кусок, используйте `/export` (пишет всю сессию в
package/dist/config.js CHANGED
@@ -21,6 +21,13 @@ export const DEFAULTS = {
21
21
  qwen: {
22
22
  model: '',
23
23
  enabled: false,
24
+ // Qwen's context window is PER-MODEL, not a site constant: Qwen3-Max is
25
+ // 258K while the 3.7/3.8-Max and Coder builds are 1M. The default assumes
26
+ // the common Qwen3-Max; `/model` re-derives it from the picked id (see
27
+ // qwenContextWindow in src/qwen-ui.ts) and `/config set qwen.contextLimit`
28
+ // overrides it. Kept SEPARATE from ui.contextLimit (DeepSeek's 1M) so one
29
+ // backend's window never becomes the other's denominator.
30
+ contextLimit: 258_048,
24
31
  // Qwen DOES rate-limit a rapid burst: measured live, the 5th back-to-back
25
32
  // send at 0 ms gap hit "too frequent" and needed a 5-min back-off. A 1 s
26
33
  // gap was too small for a real editing session, so the default is now 4 s
@@ -237,6 +244,14 @@ export const CONFIG_SCHEMA = [
237
244
  labelKey: 'cfg.f.qwen_model',
238
245
  groupKey: 'cfg.group.browser',
239
246
  },
247
+ {
248
+ path: 'qwen.contextLimit',
249
+ type: 'number',
250
+ min: 10_000,
251
+ max: 10_000_000,
252
+ labelKey: 'cfg.f.qwen_contextLimit',
253
+ groupKey: 'cfg.group.browser',
254
+ },
240
255
  {
241
256
  path: 'qwen.minSendIntervalMs',
242
257
  type: 'number',
@@ -11,6 +11,10 @@ export const configMessages = {
11
11
  ru: 'Модель Qwen (id)',
12
12
  en: 'Qwen model (id)',
13
13
  },
14
+ 'cfg.f.qwen_contextLimit': {
15
+ ru: 'Qwen: размер контекста, токены (258K у Qwen3-Max, 1M у 3.7/3.8/Coder)',
16
+ en: 'Qwen: context window, tokens (258K on Qwen3-Max, 1M on 3.7/3.8/Coder)',
17
+ },
14
18
  'cfg.f.qwen_minSendIntervalMs': {
15
19
  ru: 'Qwen: пауза между отправками (мс)',
16
20
  en: 'Qwen: pause between sends (ms)',
@@ -40,8 +44,8 @@ export const configMessages = {
40
44
  en: 'Full-screen TUI mode (alternate screen)',
41
45
  },
42
46
  'cfg.f.ui_mouseScroll': {
43
- ru: 'Прокрутка лога колесом мыши (выделение текста — Shift+протяжка)',
44
- en: 'Mouse-wheel log scrolling (select text with Shift+drag)',
47
+ ru: 'Прокрутка лога колесом мыши (выделение текста — Shift+клик)',
48
+ en: 'Mouse-wheel log scrolling (select text with Shift+click)',
45
49
  },
46
50
  'cfg.f.maxIterations': {
47
51
  ru: 'Лимит итераций на задачу',
package/dist/i18n/help.js CHANGED
@@ -78,8 +78,8 @@ export const helpMessages = {
78
78
  en: 'Ctrl+← / Ctrl+→ move by words',
79
79
  },
80
80
  'help.key.slash': {
81
- ru: '/ + Tab подсказка и автодополнение slash-команд (Ctrl+N/P — листать)',
82
- en: '/ + Tab slash-command hints and completion (Ctrl+N/P to page)',
81
+ ru: '/ + Tab подсказка и автодополнение slash-команд (Ctrl+N/P — вверх/вниз)',
82
+ en: '/ + Tab slash-command hints and completion (Ctrl+N/P — up/down)',
83
83
  },
84
84
  'help.key.newline': {
85
85
  ru: 'Ctrl+J / Ctrl+Enter новая строка (Shift+Enter в терминалах с поддержкой)',
@@ -98,12 +98,12 @@ export const helpMessages = {
98
98
  en: 'Esc, Ctrl+C abort current generation',
99
99
  },
100
100
  'help.key.scroll': {
101
- ru: 'PgUp / PgDn листать диалог (Home/End — в начало/конец) [full-screen]',
102
- en: 'PgUp / PgDn scroll the dialog (Home/End — top/bottom) [full-screen]',
101
+ ru: 'PgUp / PgDn вверх/вниз по диалогу (Home/End — в начало/конец) [full-screen]',
102
+ en: 'PgUp / PgDn up/down the dialog (Home/End — top/bottom) [full-screen]',
103
103
  },
104
104
  'help.key.wheel': {
105
- ru: 'колесо мыши скролл диалога; выделение — Shift+протяжка',
106
- en: 'mouse wheel scroll the dialog; select text with Shift+drag',
105
+ ru: 'колесо мыши скролл диалога; выделение — Shift+клик',
106
+ en: 'mouse wheel scroll the dialog; select text with Shift+click',
107
107
  },
108
108
  'help.key.bang': {
109
109
  ru: '!команда выполнить shell-команду напрямую (мимо модели)',
@@ -18,10 +18,15 @@ export const spinnerMessages = {
18
18
  // flight. 'paused'/'settled' have no label (see LineEditor._stateLabel).
19
19
  'state.generating': { ru: '▶ генерация', en: '▶ generating' },
20
20
  'state.settled': { ru: '✓ готово', en: '✓ done' },
21
+ // Shown in the status line while the full-screen log is scrolled up.
22
+ 'status.scrolled': {
23
+ ru: '↑ прокручено на {n} стр. · PgDn/End',
24
+ en: '↑ scrolled {n} lines · PgDn/End',
25
+ },
21
26
  'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
22
27
  'editor.page_hint': {
23
- ru: '(Ctrl+N/Ctrl+P — листать, Tab — вставить)',
24
- en: '(Ctrl+N/Ctrl+P to page, Tab to insert)',
28
+ ru: '(Ctrl+N/Ctrl+P — вверх/вниз, Tab — вставить)',
29
+ en: '(Ctrl+N/Ctrl+P — up/down, Tab — insert)',
25
30
  },
26
31
  'editor.answer': { ru: '● Ответ', en: '● Answer' },
27
32
  // Shown in the full-screen log viewport before the first turn, so a fresh
@@ -32,8 +37,8 @@ export const spinnerMessages = {
32
37
  },
33
38
  // One-line key hint UNDER the input box (full-screen mode).
34
39
  'editor.keys_hint': {
35
- ru: 'выделение — Shift+протяжка · колесо — скролл · PgUp/PgDn листают · Home/End — в начало/конец',
36
- en: 'select — Shift+drag · wheel — scroll · PgUp/PgDn scroll · Home/End top/bottom',
40
+ ru: 'выделение — Shift+клик · колесо — скролл · PgUp/PgDn — вверх/вниз · Home/End — в начало/конец',
41
+ en: 'select — Shift+click · wheel — scroll · PgUp/PgDn — up/down · Home/End — top/bottom',
37
42
  },
38
43
  // Reverse search (Ctrl+R) status line. The query may be empty.
39
44
  'editor.search_prompt': {
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import { applyBrowserLiveConfig, applyStoreLiveConfig } from './config-live.js';
33
33
  import { buildHandoffText, saveProviderHandoff, readProviderHandoff, clearProviderHandoff, } from './provider-handoff.js';
34
34
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
35
35
  import { createCliArgs } from './cli-args.js';
36
+ import { qwenContextWindow } from './qwen-ui.js';
36
37
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
37
38
  import { extractCodeBlocks } from './code-blocks.js';
38
39
  import { contentWidth } from './width.js';
@@ -660,6 +661,42 @@ function providerAnswerTimeoutMs() {
660
661
  function browserProviderUrl() {
661
662
  return providerChatUrlPrefix(config.provider);
662
663
  }
664
+ /**
665
+ * The context window (tokens) for the ACTIVE provider. The two backends
666
+ * advertise different windows and Qwen's is PER-MODEL, so a single global
667
+ * number would either overstate Qwen's (258K models) or understate the 1M
668
+ * builds — and the number is the denominator of the auto-compact threshold,
669
+ * so an error directly delays (or prematurely fires) compaction.
670
+ *
671
+ * `config.qwen.contextLimit` / `ui.contextLimit` are the operator-tunable
672
+ * values; `/model` rewrites the Qwen one from the picked model id.
673
+ */
674
+ function effectiveContextLimit() {
675
+ if (config.provider === 'qwen') {
676
+ return config.qwen.contextLimit > 0
677
+ ? config.qwen.contextLimit
678
+ : config.ui.contextLimit;
679
+ }
680
+ return config.ui.contextLimit;
681
+ }
682
+ /**
683
+ * Re-derive `qwen.contextLimit` from a picked model id and persist it. Called
684
+ * by `/model` on an explicit pick (never on startup), so a manual
685
+ * `/config set qwen.contextLimit` survives until the operator switches model
686
+ * again. An UNKNOWN model id leaves the limit alone: guessing would either
687
+ * fire auto-compact too early or too late. The live editor is repainted by
688
+ * the caller (it owns the `editor` reference); this only touches config.
689
+ */
690
+ function applyModelContextLimit(model) {
691
+ const window = qwenContextWindow(model);
692
+ if (window === null)
693
+ return;
694
+ config.qwen.contextLimit = window;
695
+ try {
696
+ writeConfigValue('home', 'qwen.contextLimit', String(window));
697
+ }
698
+ catch { }
699
+ }
663
700
  /**
664
701
  * Build the browser for a provider. Called at startup AND on a live
665
702
  * `/provider` switch, so both paths construct the class the same way. The
@@ -756,8 +793,9 @@ async function printRestoredHistory(browser, ui, chatId = null) {
756
793
  out(theme.dim(t('chats.history_tokens', { v: String(restoredTokens) })));
757
794
  // Warn when the restored chat is near the context limit, and suggest
758
795
  // /compact. The threshold mirrors the auto-compact one so the operator
759
- // sees the same signal whether or not auto-compact is enabled.
760
- const limit = config.ui.contextLimit;
796
+ // sees the same signal whether or not auto-compact is enabled. The limit
797
+ // is the ACTIVE provider's window (Qwen's is per-model).
798
+ const limit = effectiveContextLimit();
761
799
  if (limit > 0) {
762
800
  const pct = (restoredTokens / limit) * 100;
763
801
  if (pct >= 80) {
@@ -1292,8 +1330,10 @@ async function main() {
1292
1330
  // Task-list summary ("tasks: 2/5") before the context counter. Pulled on
1293
1331
  // every render, like the queue badge.
1294
1332
  ed.onTasksQuery = () => todosSummary();
1295
- // Use the configured context window for the fill percentage/color.
1296
- ed.setContextLimit(config.ui.contextLimit);
1333
+ // Use the configured context window for the fill percentage/color. The
1334
+ // window is PROVIDER-dependent: Qwen's is per-model (258K/1M, see
1335
+ // qwenContextWindow), DeepSeek's is the ui.contextLimit.
1336
+ ed.setContextLimit(effectiveContextLimit());
1297
1337
  // Toggle icons (🧠 deep thinking, 🌐 web search) before the context
1298
1338
  // counter. The editor pulls the live state on every render.
1299
1339
  ed.onToggleQuery = () => browser.getToggleStatesSync();
@@ -1380,7 +1420,7 @@ async function main() {
1380
1420
  chatId: currentChatId,
1381
1421
  queueLength: pendingQueue.length,
1382
1422
  tokens: browser.getLastTokenUsage(),
1383
- contextLimit: config.ui.contextLimit,
1423
+ contextLimit: effectiveContextLimit(),
1384
1424
  tasks: todosSummary(),
1385
1425
  locale: currentLocale,
1386
1426
  });
@@ -1690,10 +1730,13 @@ async function main() {
1690
1730
  }
1691
1731
  // The context window size feeds the status-bar percentage and its color.
1692
1732
  // Apply it to the live editor immediately (the value is a plain number,
1693
- // no restart needed).
1694
- if (path === 'ui.contextLimit' && typeof value === 'number') {
1733
+ // no restart needed). A change to EITHER provider's limit matters: the
1734
+ // Qwen one is what a /model pick rewrites, and the active provider is
1735
+ // what decides which value the editor shows.
1736
+ if ((path === 'ui.contextLimit' || path === 'qwen.contextLimit') &&
1737
+ typeof value === 'number') {
1695
1738
  if (editor) {
1696
- editor.setContextLimit(value);
1739
+ editor.setContextLimit(effectiveContextLimit());
1697
1740
  editor.refreshStatus();
1698
1741
  }
1699
1742
  }
@@ -2556,6 +2599,16 @@ async function main() {
2556
2599
  config.qwen.model = picked;
2557
2600
  if (typeof qb.model === 'string')
2558
2601
  qb.model = picked;
2602
+ // Derive the context window from the picked model: Qwen windows
2603
+ // are per-model (258K vs 1M), and this number is the denominator
2604
+ // of the auto-compact threshold. The operator's manual
2605
+ // `/config set qwen.contextLimit` still wins afterwards (this
2606
+ // only runs on an explicit pick).
2607
+ applyModelContextLimit(picked);
2608
+ if (editor) {
2609
+ editor.setContextLimit(effectiveContextLimit());
2610
+ editor.refreshStatus();
2611
+ }
2559
2612
  try {
2560
2613
  writeConfigValue('home', 'qwen.model', picked);
2561
2614
  }
@@ -2571,6 +2624,11 @@ async function main() {
2571
2624
  config.qwen.model = arg;
2572
2625
  if (typeof qb.model === 'string')
2573
2626
  qb.model = arg;
2627
+ applyModelContextLimit(arg);
2628
+ if (editor) {
2629
+ editor.setContextLimit(effectiveContextLimit());
2630
+ editor.refreshStatus();
2631
+ }
2574
2632
  try {
2575
2633
  writeConfigValue('home', 'qwen.model', arg);
2576
2634
  }
@@ -3257,7 +3315,7 @@ async function main() {
3257
3315
  saveLastChat(chatId, currentWorkdir);
3258
3316
  }
3259
3317
  },
3260
- contextLimit: config.ui.contextLimit,
3318
+ contextLimit: effectiveContextLimit(),
3261
3319
  getTokenUsage: () => browser.getLastTokenUsage(),
3262
3320
  goal: sessionGoal,
3263
3321
  });
@@ -3372,7 +3430,7 @@ async function main() {
3372
3430
  mcpTools: mcpStatus.toolCount,
3373
3431
  transcriptOk: !!transcript.file,
3374
3432
  authSaved: authMarkerExists(),
3375
- contextLimit: config.ui.contextLimit,
3433
+ contextLimit: effectiveContextLimit(),
3376
3434
  hasOrigin: gitOk
3377
3435
  ? (await runGitArgs(['remote'], currentWorkdir, 5000)).includes('origin')
3378
3436
  : undefined,
@@ -3890,7 +3948,7 @@ async function main() {
3890
3948
  }
3891
3949
  : null,
3892
3950
  autoCompactPct: config.browser.autoCompactPct,
3893
- contextLimit: config.ui.contextLimit,
3951
+ contextLimit: effectiveContextLimit(),
3894
3952
  getTokenUsage: () => browser.getLastTokenUsage(),
3895
3953
  goal: sessionGoal,
3896
3954
  // Completion-goal mode (Claude Code parity): the goal is a CONDITION,
package/dist/qwen-ui.js CHANGED
@@ -76,6 +76,47 @@ export const NEW_CHAT_SELECTOR = '.new-chat, button[aria-label="New Chat" i]';
76
76
  // model itself is chosen via the config (sent with each completion request)
77
77
  // unless a per-session override is set.
78
78
  export const MODEL_TRIGGER_SELECTOR = '.wms-trigger';
79
+ // ---------- context windows ----------
80
+ // Qwen's per-model context window (tokens). The window is a property of the
81
+ // MODEL, not the site: Qwen3-Max is 258K while the 3.7/3.8-Max and Coder
82
+ // builds are 1M. zames uses the number ONLY as the denominator for the
83
+ // status-bar fill percentage and the auto-compact threshold, so a wrong value
84
+ // either fires compaction far too late (window overstated) or too early.
85
+ //
86
+ // Matching is done on a NORMALIZED id: lowercased, non-alphanumerics dropped,
87
+ // so `qwen3-max`, `Qwen3-Max` and `qwen3_max` all hit the same row. The list is
88
+ // ordered MOST SPECIFIC first: a `qwen3-coder-plus` id must not stop at a
89
+ // generic `qwen3` rule. Values verified against the public model cards
90
+ // (contextwindows.dev / haimaker.ai) in October 2026.
91
+ const CONTEXT_WINDOWS = [
92
+ // The 1M builds MUST be checked before the 258K `qwen3-max` family, or
93
+ // `qwen3.7-max` would match the shorter `max` rule first.
94
+ [/qwen3\.?7.?max/, 1_000_000],
95
+ [/qwen3\.?8.?max/, 1_000_000],
96
+ [/qwen3coder/, 1_000_000],
97
+ [/qwenlong/, 10_000_000],
98
+ [/qwen3max/, 258_048],
99
+ // Generic fallback: the OTHER qwen3 builds (Plus/Turbo/Flash) advertised 1M
100
+ // on their model cards, so an unknown qwen3 id gets 1M rather than the min.
101
+ [/qwen3/, 1_000_000],
102
+ ];
103
+ /** Lowercase and drop every non-alphanumeric so `qwen3.7-max` -> `qwen37max`. */
104
+ export function normalizeModelId(model) {
105
+ return String(model || '')
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9]/g, '');
108
+ }
109
+ /** The context window (tokens) for a Qwen model id, or null when unknown. */
110
+ export function qwenContextWindow(model) {
111
+ const id = normalizeModelId(model);
112
+ if (!id)
113
+ return null;
114
+ for (const [re, window] of CONTEXT_WINDOWS) {
115
+ if (re.test(id))
116
+ return window;
117
+ }
118
+ return null;
119
+ }
79
120
  // ---------- age gate / cookies ----------
80
121
  // A first-time visitor must confirm their birth year before chatting. The
81
122
  // dialog is an ant-modal; the Continue button is the black footer button.
@@ -155,7 +155,11 @@ export function StatusBar({ state, divider = true, }) {
155
155
  // When the log is scrolled up, show a marker in the status line (NOT as a
156
156
  // separate row below the input — that pushed the panel up and made the input
157
157
  // jump). It replaces the spinner text while scrolled, so it never adds a row.
158
- const scrollNote = state.scroll > 0 ? `↑ прокручено на ${state.scroll} стр. · PgDn/End` : '';
158
+ const scrollNote = state.scroll > 0
159
+ ? translate(state.locale)('status.scrolled', {
160
+ n: String(state.scroll),
161
+ })
162
+ : '';
159
163
  const leftText = scrollNote || prefix + left;
160
164
  const showLeft = !!leftText;
161
165
  // Keep the row on ONE line. The left status gets at most a third of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.75.3",
3
+ "version": "2.75.5",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "self-smoke": "tsx scripts/self-smoke.mts",
36
36
  "qwen-smoke": "tsx scripts/qwen-smoke.mts",
37
37
  "qwen-models-smoke": "tsx scripts/qwen-models-smoke.mts",
38
+ "sched-smoke": "tsx scripts/sched-smoke.mts",
38
39
  "render-demo": "tsx scripts/render-demo.mts",
39
40
  "render-how": "tsx scripts/render-how-it-works.mts",
40
41
  "build:readme": "tsx scripts/build-readme.mts",
@@ -0,0 +1,150 @@
1
+ // sched-smoke: ЖИВАЯ проверка планировщика (/loop, /cron, /jobs) в РЕАЛЬНОМ
2
+ // zames.
3
+ //
4
+ // Ключевая идея: TUI-ввод в песочнице проверить нельзя (Ink требует настоящий
5
+ // интерактивный терминал, а pty.fork/script в CI его не дают — панель ввода
6
+ // просто не рендерится). Поэтому проверяем ДВЕ вещи независимо и по-настоящему:
7
+ //
8
+ // 1) ЧИСТЫЕ модули планировщика (src/scheduler.ts) — экспортируемый код
9
+ // ядра, который вызывает main loop: parseInterval, parseCron, nextCronTime,
10
+ // Scheduler.addLoop/addCron/due/remove/clear, formatJobLine.
11
+ // 2) ФАКТИЧЕСКУЮ РЕАКЦИЮ main loop: планировщик реально enqueue'ит задачу в
12
+ // очередь, и она попадает в тот же pendingQueue, что и ввод оператора.
13
+ // Это воспроизводим без браузера: Scheduler + due() + push в очередь —
14
+ // ровно то, что делает 1-секундный tick в index.ts.
15
+ //
16
+ // Плюс: /loop, /cron, /jobs присутствуют в реальном списке команд (SLASH_COMMANDS)
17
+ // и их описания локализованы — чтобы оператор их видел в подсказке и /help.
18
+ //
19
+ // Изоляция: HOME -> временная папка ДО импорта модулей (как в self-smoke),
20
+ // живой ~/.zames не трогается. Флагами можно попросить --headed, но браузер
21
+ // здесь не поднимается вообще: сценарии не требуют сети.
22
+ import fs from 'fs'
23
+ import path from 'path'
24
+ import os from 'os'
25
+
26
+ const REAL_HOME = os.homedir()
27
+ if (!process.env.ZAMES_SCHED_SMOKE_HOME) {
28
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-sched-'))
29
+ process.env.HOME = tmp
30
+ process.env.ZAMES_SCHED_SMOKE_HOME = tmp
31
+ fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
32
+ const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
33
+ if (fs.existsSync(realCfg)) {
34
+ fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
35
+ }
36
+ }
37
+ const SMOKE_HOME = process.env.ZAMES_SCHED_SMOKE_HOME as string
38
+ console.log('sched-smoke: HOME=' + SMOKE_HOME)
39
+
40
+ const { Scheduler, parseInterval, parseCron, nextCronTime, formatJobLine } =
41
+ await import('../src/scheduler.ts')
42
+ const { SLASH_COMMANDS } = await import('../src/commands.ts')
43
+ const { translate } = await import('../src/i18n.ts')
44
+
45
+ let failures = 0
46
+ const check = (name: string, ok: boolean, note?: string): void => {
47
+ console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
48
+ if (!ok) failures++
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // 1. /loop: интервал -> задача
53
+ // ---------------------------------------------------------------------------
54
+ console.log('\n--- /loop ---')
55
+ check('parseInterval 10s = 10000ms', parseInterval('10s') === 10_000)
56
+ check('parseInterval 5m = 300000ms', parseInterval('5m') === 300_000)
57
+ check('parseInterval 2h = 7200000ms', parseInterval('2h') === 7_200_000)
58
+ check('parseInterval bare 30 = 30000ms', parseInterval('30') === 30_000)
59
+ check('parseInterval мусор = null', parseInterval('abc') === null)
60
+
61
+ const s = new Scheduler()
62
+ const loop = s.addLoop(parseInterval('20s')!, 'проверь состояние', 1_000_000)
63
+ check('addLoop создал job #' + loop.id, loop.kind === 'loop' && loop.id === 1)
64
+ check('nextAt = now + interval', loop.nextAt === 1_000_000 + 20_000)
65
+ console.log(' ' + formatJobLine(loop, 1_000_000))
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 2. /cron: выражение -> задача
69
+ // ---------------------------------------------------------------------------
70
+ console.log('\n--- /cron ---')
71
+ check('parseCron */5 * * * *', parseCron('*/5 * * * *') !== null)
72
+ check('parseCron 0 9 * * 1-5', parseCron('0 9 * * 1-5') !== null)
73
+ check('parseCron отверг 4 поля', parseCron('* * * *') === null)
74
+ check('parseCron отверг имя дня MON', parseCron('0 9 * * MON') === null)
75
+ const nct = nextCronTime('*/2 * * * *', 1_000_000_000_000)
76
+ check(
77
+ 'nextCronTime вернул будущее время',
78
+ typeof nct === 'number' && nct > 1_000_000_000_000,
79
+ )
80
+ const cron = s.addCron('*/2 * * * *', 'проверь сборку', 1_000_000)
81
+ check('addCron создал job #' + cron!.id, cron !== null && cron.kind === 'cron')
82
+ console.log(' ' + formatJobLine(cron!, 1_000_000))
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // 3. Главное: tick реально enqueue'ит задачу (то, что делает 1-сек таймер)
86
+ // ---------------------------------------------------------------------------
87
+ console.log('\n--- tick -> очередь (как в main loop) ---')
88
+ const pendingQueue: Array<{ text: string; jobId?: number }> = []
89
+ // Эмулируем 1-секундный tick из index.ts: due(now) -> push в очередь.
90
+ const tick = (now: number): number => {
91
+ const due = s.due(now)
92
+ for (const job of due) {
93
+ if (pendingQueue.some((m) => m.jobId === job.id)) continue
94
+ pendingQueue.push({ text: job.task, jobId: job.id })
95
+ }
96
+ return due.length
97
+ }
98
+ check('до срока tick ничего не даёт', tick(1_000_000 + 1_000) === 0)
99
+ check('cron */2 сработал по сроку', tick(cron!.nextAt) >= 1)
100
+ check(
101
+ 'задача попала в очередь',
102
+ pendingQueue.some((m) => m.jobId === cron!.id),
103
+ )
104
+ check(
105
+ 'в очереди текст задачи',
106
+ pendingQueue.some((m) => m.text === 'проверь сборку'),
107
+ )
108
+ // Даже если tick вызовут повторно, дубликата нет (та же логика дедупа).
109
+ const before = pendingQueue.length
110
+ tick(cron!.nextAt)
111
+ check('повторный tick не дублирует задачу', pendingQueue.length === before)
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // 4. /jobs: удаление и очистка
115
+ // ---------------------------------------------------------------------------
116
+ console.log('\n--- /jobs ---')
117
+ check('в списке 2 задачи', s.count() === 2)
118
+ check('remove(1) удалил loop', s.remove(1) === true)
119
+ check('count после rm = 1', s.count() === 1)
120
+ check('remove(999) несуществующей = false', s.remove(999) === false)
121
+ check('clear() вернул 1', s.clear() === 1)
122
+ check('count после clear = 0', s.count() === 0)
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // 5. /loop, /cron, /jobs есть в реальном списке команд и локализованы
126
+ // ---------------------------------------------------------------------------
127
+ console.log('\n--- список команд и локализация ---')
128
+ for (const name of ['/loop', '/cron', '/jobs']) {
129
+ const entry = SLASH_COMMANDS.find((c) => c.name === name)
130
+ check(name + ' в SLASH_COMMANDS', !!entry)
131
+ if (entry) {
132
+ const ru = translate('ru')(entry.key)
133
+ const en = translate('en')(entry.key)
134
+ check(
135
+ name + ' локализован (ru/en)',
136
+ !!ru && !!en && ru !== entry.key && en !== entry.key,
137
+ ru,
138
+ )
139
+ }
140
+ }
141
+
142
+ console.log(
143
+ '\nsched-smoke: ' + (failures === 0 ? 'ALL PASS' : failures + ' FAILED'),
144
+ )
145
+ if (!process.env.ZAMES_SCHED_SMOKE_KEEP) {
146
+ try {
147
+ fs.rmSync(SMOKE_HOME, { recursive: true, force: true })
148
+ } catch {}
149
+ }
150
+ process.exit(failures === 0 ? 0 : 1)