Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-desktop-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ runs:
node-version: ${{ inputs.node-version }}

- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
uses: pnpm/action-setup@v4.4.0
with:
version: ${{ inputs.pnpm-version }}

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-desktop-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
setup-python: 'false'

- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
uses: pnpm/action-setup@v4.4.0
with:
version: 10.28.2

Expand Down
297 changes: 295 additions & 2 deletions scripts/prepare-resources/startup-shell-copy.test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';

const startupShellPath = new URL('../../ui/index.html', import.meta.url);
const startupCopyConfigPath = new URL('../../ui/startup-copy.js', import.meta.url);
const startupTaskPath = new URL('../../src-tauri/src/startup_task.rs', import.meta.url);

const STARTUP_ELEMENT_IDS = [
'startup-title',
'startup-desc',
'startup-status',
'startup-summary-label',
'startup-summary-text',
'startup-diagnostics-toggle',
'startup-diagnostics-toggle-text',
'startup-diagnostics',
'startup-stage-list',
'startup-desktop-log-label',
'startup-desktop-log-lines',
'startup-backend-log-label',
'startup-backend-log-lines',
];

class FakeElement {
constructor(id) {
this.id = id;
this.textContent = '';
this.title = '';
this.hidden = false;
this.className = '';
this.dataset = {};
this.children = [];
this.attributes = new Map();
this.listeners = new Map();
}

append(child) {
this.children.push(child);
}

replaceChildren(...children) {
this.children = children;
}

setAttribute(name, value) {
this.attributes.set(name, String(value));
}

getAttribute(name) {
return this.attributes.get(name);
}

addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
}

class FakeDocument {
constructor(ids) {
this.elements = new Map(ids.map((id) => [id, new FakeElement(id)]));
}

getElementById(id) {
return this.elements.get(id) || null;
}

createElement(tagName) {
return new FakeElement(tagName);
}
}

const extractInlineStartupScript = (source) => {
const match = source.match(/<script>\s*([\s\S]*?)\s*<\/script>\s*<\/body>/);
assert.ok(match, 'expected startup shell to include an inline bootstrap script');
return match[1];
};

const flushMicrotasks = async () => {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
};

const renderStartupShell = async ({ locale = 'zh-CN', snapshot } = {}) => {
const [source, configSource] = await Promise.all([
readFile(startupShellPath, 'utf8'),
readFile(startupCopyConfigPath, 'utf8'),
]);
const inlineScript = extractInlineStartupScript(source);
const document = new FakeDocument(STARTUP_ELEMENT_IDS);
const windowListeners = [];
const intervalCalls = [];
const invokeCalls = [];
const window = {
astrbot: {},
__TAURI_INTERNALS__: {
invoke: async (command) => {
invokeCalls.push(command);
return snapshot ?? null;
},
},
setInterval: (handler, delay) => {
intervalCalls.push({ handler, delay });
return intervalCalls.length;
},
clearInterval: () => {},
addEventListener: (type, listener, options) => {
windowListeners.push({ type, listener, options });
},
};

const context = vm.createContext({
window,
document,
navigator: { language: locale },
console,
Promise,
Object,
Array,
Number,
String,
Boolean,
JSON,
Map,
Set,
});

vm.runInContext(configSource, context, { filename: 'startup-copy.js' });
vm.runInContext(inlineScript, context, { filename: 'startup-shell-inline.js' });
await flushMicrotasks();

return {
window,
document,
windowListeners,
intervalCalls,
invokeCalls,
};
};

test('startup shell loads shared copy config, reuses applyStartupMode, and exposes a focused live region', async () => {
const source = await readFile(startupShellPath, 'utf8');
Expand Down Expand Up @@ -69,8 +205,8 @@ test('startup shell loads shared copy config, reuses applyStartupMode, and expos
);
assert.match(
source,
/if\s*\(status\.textContent\s*===\s*next\.status\)\s*return;/,
'expected startup shell to skip duplicate status announcements',
/setStatusText\(next\.status\);/,
'expected startup shell to route startup-mode status changes through the shared duplicate-announcement guard',
);

assert.match(
Expand Down Expand Up @@ -109,3 +245,160 @@ test('startup shell loads shared copy config, reuses applyStartupMode, and expos
'expected shared startup copy config to include Chinese startup copy',
);
});

test('startup diagnostics keeps localized stage summaries in frontend copy and stacks logs on narrow widths', async () => {
const source = await readFile(startupShellPath, 'utf8');
const configSource = await readFile(startupCopyConfigPath, 'utf8').catch(() => '');

assert.match(
configSource,
/en:\s*\{[\s\S]*stageSummaries:\s*\{[\s\S]*resolveLaunchPlan:\s*'Resolving launch plan'/,
'expected English stage summaries to stay in the shared frontend copy config',
);
assert.match(
configSource,
/zh:\s*\{[\s\S]*stageSummaries:\s*\{[\s\S]*resolveLaunchPlan:\s*'正在解析启动计划'/,
'expected Chinese stage summaries to stay in the shared frontend copy config',
);
assert.match(
source,
/const\s+resolveSnapshotSummary\s*=\s*\(snapshot\)\s*=>/,
'expected startup shell to resolve diagnostics summaries through a dedicated helper',
);
assert.match(
source,
/snapshot\?\.stage\s*===\s*"failed"/,
'expected startup shell to surface failure details from snapshot data only for failed startup states',
);
assert.doesNotMatch(
source,
/setSummaryText\(snapshot\.summary\)/,
'expected startup shell not to blindly reuse backend summary text for every stage',
);
assert.match(
source,
/setSummaryText\(resolveSnapshotSummary\(snapshot\)\)/,
'expected snapshot rendering to localize non-failure summaries before updating the compact row',
);
assert.match(
source,
/@media\s*\(max-width:\s*\d+px\)\s*\{[\s\S]*?\.startup-log-grid\s*\{[\s\S]*?grid-template-columns:\s*1fr;/,
'expected diagnostics log cards to collapse to one column at narrow widths',
);
assert.match(
source,
/\.startup-diagnostics\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*overflow-x:\s*hidden;/,
'expected the capped diagnostics panel to scroll vertically so stacked log cards remain reachable on narrow widths',
);
});

test('startup diagnostics keeps the live status row in sync with localized snapshot summaries', async () => {
const source = await readFile(startupShellPath, 'utf8');

assert.match(
source,
/const\s+setStatusText\s*=\s*\(value\)\s*=>/,
'expected startup shell to centralize live status updates through a helper',
);
assert.match(
source,
/if\s*\(status\.textContent\s*===\s*nextValue\)\s*return;/,
'expected live status updates to preserve duplicate-announcement restraint',
);
assert.match(
source,
/setStatusText\(resolveSnapshotSummary\(snapshot\)\)/,
'expected polled startup snapshots to keep the live status row aligned with the compact summary',
);
});

test('startup mode updates keep snapshot-controlled status and summary text aligned after polling starts', async () => {
const { document, window } = await renderStartupShell({
locale: 'zh-CN',
snapshot: {
stage: 'tcpReachable',
summary: 'TCP ready, waiting for HTTP',
items: [],
desktopLog: [],
backendLog: [],
},
});

const status = document.getElementById('startup-status');
const summaryText = document.getElementById('startup-summary-text');
const desc = document.getElementById('startup-desc');

assert.equal(status.textContent, 'TCP 已就绪,正在等待 HTTP');
assert.equal(summaryText.textContent, 'TCP 已就绪,正在等待 HTTP');

window.__astrbotSetStartupMode('panel-update');

assert.equal(
desc.textContent,
'检测到新面板版本,正在下载并应用。',
'expected startup mode changes to keep updating the descriptive copy',
);
assert.equal(
status.textContent,
'TCP 已就绪,正在等待 HTTP',
'expected startup mode changes to stop overriding the live status after snapshot polling takes over',
);
assert.equal(
summaryText.textContent,
'TCP 已就绪,正在等待 HTTP',
'expected startup mode changes to keep the compact summary synchronized with the live status after snapshot polling takes over',
);
});

test('failed snapshot prefers localized frontend failure copy when backend summary is empty or generic', async () => {
for (const summary of ['', 'Startup failed']) {
const { document } = await renderStartupShell({
locale: 'zh-CN',
snapshot: {
stage: 'failed',
summary,
items: [],
desktopLog: [],
backendLog: [],
},
});

assert.equal(
document.getElementById('startup-status').textContent,
'启动失败',
'expected failed snapshots with empty or generic backend summaries to fall back to localized frontend copy for the live status',
);
assert.equal(
document.getElementById('startup-summary-text').textContent,
'启动失败',
'expected failed snapshots with empty or generic backend summaries to fall back to localized frontend copy for the compact summary',
);
}
});

test('startup task records the desktop log offset before async startup work is spawned', async () => {
const source = await readFile(startupTaskPath, 'utf8');
const taskStartIndex = source.indexOf('pub fn spawn_startup_task');
const testsStartIndex = source.indexOf('#[cfg(test)]');
const taskSource = source.slice(
taskStartIndex,
testsStartIndex === -1 ? source.length : testsStartIndex,
);
const prepareCallIndex = taskSource.lastIndexOf('prepare_startup_panel_for_attempt(');
const spawnAsyncIndex = taskSource.indexOf('tauri::async_runtime::spawn(async move {');

assert.notEqual(
prepareCallIndex,
-1,
'expected startup task to record the desktop log start offset for the startup panel',
);
assert.notEqual(
spawnAsyncIndex,
-1,
'expected startup task to spawn its async startup work',
);
assert.ok(
prepareCallIndex < spawnAsyncIndex,
'expected startup task to capture the desktop log offset before the async startup work begins',
);
});
1 change: 1 addition & 0 deletions src-tauri/src/app_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ pub(crate) fn run() {
.invoke_handler(tauri::generate_handler![
crate::bridge::commands::desktop_bridge_is_desktop_runtime,
crate::bridge::commands::desktop_bridge_get_backend_state,
crate::bridge::commands::desktop_bridge_get_startup_panel_snapshot,
crate::bridge::commands::desktop_bridge_set_auth_token,
crate::bridge::commands::desktop_bridge_set_shell_locale,
crate::bridge::commands::desktop_bridge_get_app_update_channel,
Expand Down
Loading