Skip to content
Merged
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
16 changes: 16 additions & 0 deletions apps/desktop/e2e/goal-dialog-budget.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,4 +82,20 @@ test('an unsendable budget blocks Start instead of arming a different one', asyn
};
})
.toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 });

// The Host read above proves persistence; this proves the same broadcast now
// reaches the provider-owned chat projection without AppShell reading it.
const goalContext = page
.getByRole('region', { name: '任务上下文' })
.filter({ visible: true });
await expect(
goalContext
.getByText(/目标 0 \/ 25 · .* · 0 \/ 5k/)
.filter({ visible: true }),
).toBeVisible();
await expect(
goalContext
.getByRole('button', { name: '暂停自主执行目标(已进行 0/25 轮)' })
.filter({ visible: true }),
).toBeVisible();
});
5 changes: 2 additions & 3 deletions apps/desktop/renderer-architecture.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,7 +921,6 @@
"useCommandPalette": 1,
"useComposerAttachments": 1,
"useEffect": 14,
"useGoalController": 1,
"useKeyboardHelp": 1,
"useLayoutEffect": 2,
"useModuleHubController": 1,
Expand DownExpand Up@@ -1060,8 +1059,8 @@
"@maka/ui/icons": 1,
"react": 1
},
"importSpecifiers": 187,
"nonTriviaTokens": 15855
"importSpecifiers": 186,
"nonTriviaTokens": 15825
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/scripts/check-renderer-architecture.test.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1054,6 +1054,52 @@ describe('renderer architecture checker fixtures', () => {
);
});

it('rejects a feature controller Hook returning to AppShell after provider migration', async () => {
const providerOwnedAppShell = `
import { GoalProvider } from './features/goals/index.js';
export const AppShell = GoalProvider;
`;
await withDesktopFixture(
{
[TRANSITIVE_APP_SHELL_PATH]: providerOwnedAppShell,
'src/renderer/features/goals/index.ts': `
export const GoalProvider = true;
export function useGoalController() { return true; }
`,
},
async (desktopRoot) => {
const seedConfig = transitiveAppShellSeedConfig();
const providerOwnedConfig = generateArchitectureConfig(desktopRoot, seedConfig);

await writeFile(
join(desktopRoot, TRANSITIVE_APP_SHELL_PATH),
`
import { GoalProvider, useGoalController } from './features/goals/index.js';
export const AppShell = [GoalProvider, useGoalController()];
`,
'utf8',
);
const regressedConfig = generateArchitectureConfig(
desktopRoot,
providerOwnedConfig,
);
const violations = violationsFor(
desktopRoot,
regressedConfig,
providerOwnedConfig,
);

assertHasViolation(
violations,
/^src\/renderer\/app-shell\.ts: hookCalls debt increased from 0 to 1$/u,
);
assertHasViolation(
violations,
/^src\/renderer\/app-shell\.ts: new or increased hookCalls debt useGoalController$/u,
);
},
);
});
it('rejects bridge and environment capability growth inside a transitive legacy AppShell helper', async () => {
await withDesktopFixture(
transitiveAppShellFiles(`
Expand Down
216 changes: 216 additions & 0 deletions apps/desktop/src/main/__tests__/goal-provider-scope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement, Fragment } from 'react';
import type { GoalState } from '@maka/core/goal';
import {
LocaleProvider,
useChatViewGoalProjection,
useComposerGoalProjection,
} from '@maka/ui';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
createFakeGoalServices,
GoalProvider,
GoalServicesProvider,
type GoalServices,
} from '../../renderer/features/goals/testing.js';

type ComposerProbeProps = ReturnType<typeof useComposerGoalProjection>;
type IndicatorProbeProps = ReturnType<typeof useChatViewGoalProjection>;

let shellRenders = 0;
let composerRenders = 0;
let indicatorRenders = 0;
let latestComposer: ComposerProbeProps | undefined;
let latestIndicator: IndicatorProbeProps | undefined;

function ComposerProbe() {
const props = useComposerGoalProjection();
composerRenders += 1;
latestComposer = props;
return null;
}

function IndicatorProbe() {
const props = useChatViewGoalProjection();
indicatorRenders += 1;
latestIndicator = props;
return null;
}

function ShellProbe() {
shellRenders += 1;
return createElement(
Fragment,
null,
createElement(ComposerProbe),
createElement(IndicatorProbe),
);
}

function goal(tokensNow: number): GoalState {
return {
id: 'goal-a',
revision: tokensNow,
sessionId: 'a',
condition: 'Finish a',
status: 'active',
setAt: 100,
iterations: 2,
maxIterations: 9,
consecutiveNoProgress: 0,
blockCap: 3,
tokenBudget: 500,
tokensAtStart: 10,
tokensNow,
tokensBaselinePending: false,
};
}

function renderProvider(
root: ReturnType<typeof installReactRenderer>['root'],
services: GoalServices,
reportError: (sessionId: string, title: string, description?: string) => void,
enabled = true,
) {
root.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(
GoalServicesProvider,
{ services },
createElement(
GoalProvider,
{ activeSessionId: 'a', canOpenDialog: enabled, reportError },
createElement(ShellProbe),
),
),
}),
);
}

afterEach(() => {
shellRenders = 0;
composerRenders = 0;
indicatorRenders = 0;
latestComposer = undefined;
latestIndicator = undefined;
cleanupFakeDom();
});

describe('GoalProvider render scope', () => {
it('updates only the projection whose reader changed', async () => {
const { root } = installReactRenderer();
let current = goal(60);
let emit: ((sessionId: string | undefined) => void) | undefined;
const defaults = createFakeGoalServices();
const services = createFakeGoalServices({
goal: {
...defaults.goal,
get: async () => current,
subscribeChanges: (handler) => {
emit = handler;
return () => undefined;
},
},
});

await act(async () => renderProvider(root, services, () => undefined));
assert.equal(latestComposer?.goalActive, true);
assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 60);
assert.equal(shellRenders, 1);

const composerBeforeRefresh = composerRenders;
const indicatorBeforeRefresh = indicatorRenders;
current = goal(75);
await act(async () => emit?.('a'));

assert.equal(shellRenders, 1);
assert.equal(composerRenders, composerBeforeRefresh);
assert.equal(indicatorRenders, indicatorBeforeRefresh + 1);
assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 75);

const composerBeforeDialog = composerRenders;
const indicatorBeforeDialog = indicatorRenders;
await act(async () => latestComposer?.onSetGoal?.());
assert.equal(shellRenders, 1);
assert.equal(composerRenders, composerBeforeDialog);
assert.equal(indicatorRenders, indicatorBeforeDialog);

await act(async () => root.unmount());
});

it('withholds the command when disabled and reports failures to the latest owner', async () => {
const { root } = installReactRenderer();
const firstErrors: string[] = [];
const latestErrors: string[] = [];
const defaults = createFakeGoalServices();
const services = createFakeGoalServices({
goal: {
...defaults.goal,
get: async () => goal(60),
pause: async () => {
throw new Error('offline');
},
},
});

await act(async () =>
renderProvider(
root,
services,
(_sessionId, _title, description) => firstErrors.push(description ?? ''),
false,
),
);
assert.equal(latestComposer?.goalActive, true);
assert.equal(latestComposer?.onSetGoal, undefined);

await act(async () =>
renderProvider(
root,
services,
(_sessionId, _title, description) => latestErrors.push(description ?? ''),
),
);
assert.equal(typeof latestComposer?.onSetGoal, 'function');
await act(async () => {
latestIndicator?.goalIndicator?.onPause?.();
await Promise.resolve();
});

assert.deepEqual(firstErrors, []);
assert.equal(latestErrors.length, 1);
assert.match(latestErrors[0] ?? '', /still be continuing/);

await act(async () => root.unmount());
});

it('defaults standalone UI readers to an inactive Goal projection', async () => {
const { root } = installReactRenderer();
await act(async () => root.render(createElement(ShellProbe)));
assert.equal(latestComposer?.goalActive, false);
assert.equal(latestComposer?.onSetGoal, undefined);
assert.equal(latestIndicator?.goalIndicator, undefined);
await act(async () => root.unmount());
});
});
Loading