Skip to content

Commit 2d7198b

Browse files
authored
Merge pull request #3881 from bobleer/bob/fix-mobile-checks
fix(mobile): restore mobile architecture check and HarmonyOS test harness
2 parents f032e02 + b6f250a commit 2d7198b

13 files changed

Lines changed: 210 additions & 44 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"models-dev:update": "node scripts/update-models-dev-snapshot.mjs",
7070
"check:build-prereqs": "node scripts/check-build-prereqs.mjs",
7171
"harmony:architecture": "node scripts/check-harmonyos-architecture.mjs",
72-
"mobile:architecture": "node scripts/check-mobile-architecture.mjs",
72+
"mobile:architecture": "node scripts/check-mobile-architecture.mjs && node --test scripts/mobile-client-build.contract.test.mjs",
7373
"mobile:ui:generate": "node scripts/mobile-ui-design-system.mjs",
7474
"mobile:ui:check": "node scripts/mobile-ui-design-system.mjs --check && node scripts/mobile-ui-preview.mjs --check",
7575
"mobile:ui:preview": "node scripts/mobile-ui-design-system.mjs && node scripts/mobile-ui-preview.mjs",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import test from 'node:test';
5+
6+
// Cross-platform source contracts belong to the repository, not a shared
7+
// runtime module that would then need to reach into a platform's implementation.
8+
const root = path.resolve(import.meta.dirname, '..');
9+
const source = relative => fs.readFileSync(path.join(root, relative), 'utf8');
10+
const services = 'src/apps/mobile/harmonyos/entry/src/main/ets/services';
11+
12+
function protocolVersion(file, declaration) {
13+
const match = source(file).match(declaration);
14+
assert.ok(match, `Missing protocol version declaration in ${file}`);
15+
return Number(match[1]);
16+
}
17+
18+
test('mobile and web protocol versions match the Rust account contract', () => {
19+
const expected = protocolVersion(
20+
'src/crates/contracts/product-domains/src/account.rs',
21+
/^pub const CLIENT_PROTOCOL_VERSION: u32 = (\d+);$/m,
22+
);
23+
for (const [file, declaration] of [
24+
['src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/ClientBuild.kt',
25+
/^public const val CLIENT_PROTOCOL_VERSION: Int = (\d+)$/m],
26+
[`${services}/ClientBuild.ets`, /^export const CLIENT_PROTOCOL_VERSION: number = (\d+);$/m],
27+
['src/shared/relay-transport/ClientBuild.ts', /^export const CLIENT_PROTOCOL_VERSION = (\d+);$/m],
28+
]) {
29+
assert.equal(protocolVersion(file, declaration), expected, `${file} must match the Rust contract`);
30+
}
31+
});
32+
33+
test('HarmonyOS reports its bundle build and protocol on login and every handshake', () => {
34+
// Preserve the transport assertions formerly owned by the shared JVM suite.
35+
// The version string is resolved from this platform's bundle at runtime.
36+
const realtime = source(`${services}/AccountRealtime.ets`);
37+
assert.ok(realtime.includes('clientVersion: clientVersion()'));
38+
assert.ok(realtime.includes('clientProtocol: CLIENT_PROTOCOL_VERSION'));
39+
const client = source(`${services}/CloudAccountClient.ets`);
40+
assert.ok(client.includes('clientVersion: clientVersion(), clientProtocol: CLIENT_PROTOCOL_VERSION'));
41+
});

‎src/apps/mobile/harmonyos/entry/src/main/ets/services/ClientBuild.ets‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { bundleManager } from '@kit.AbilityKit';
2121
* `openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION` in
2222
* `src/crates/contracts/product-domains/src/account.rs` and to the Kotlin
2323
* `CLIENT_PROTOCOL_VERSION` the other mobile clients share;
24-
* `ClientBuildContractTest` in `shared/core-transport` pins them together so
24+
* `scripts/mobile-client-build.contract.test.mjs` pins them together so
2525
* one side can never drift.
2626
*/
2727
export const CLIENT_PROTOCOL_VERSION: number = 2;

‎src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/NativeCryptoLoad.test.ets‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
import { describe, it, expect } from '@ohos/hypium';
2-
import { CloudAccountCrypto } from '../../../main/ets/services/CloudAccountCrypto';
2+
import openbitfunCrypto from 'libopenbitfun_crypto.so';
3+
import { Encoding } from '../../../main/ets/services/Encoding';
4+
5+
// The account password login that wrapped this binding is retired, so the load
6+
// goes straight to the native Argon2id work the library still ships.
7+
const LOAD_MEMORY_KIB: number = 16 * 1024;
8+
const LOAD_PASSES: number = 2;
9+
const LOAD_LANES: number = 1;
10+
11+
function nativeArgon2id(input: Uint8Array, salt: Uint8Array): Promise<Uint8Array> {
12+
return openbitfunCrypto.argon2idRaw(input, salt, LOAD_MEMORY_KIB, LOAD_PASSES, LOAD_LANES);
13+
}
314

415
export default function nativeCryptoLoadTest() {
516
describe('NativeCryptoLoad', () => {
617
it('keeps the ArkTS event loop responsive during concurrent Argon2id work', 0, async () => {
718
const salt = new Uint8Array([0x42, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x2d, 0x31,
819
0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]);
20+
const input = Encoding.utf8ToBytes('openbitfun-device-load');
921
let heartbeatTicks = 0;
1022
const heartbeatId = setInterval(() => {
1123
heartbeatTicks += 1;
1224
}, 5);
1325
const startedAt = Date.now();
1426

1527
const results = await Promise.all([
16-
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 }),
17-
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 }),
18-
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 })
28+
nativeArgon2id(input, salt),
29+
nativeArgon2id(input, salt),
30+
nativeArgon2id(input, salt)
1931
]);
2032
clearInterval(heartbeatId);
2133

‎src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ import {
110110
} from '../main/ets/services/CloudAccountClient';
111111
import {
112112
CloudAccountSessionStore,
113-
PersistedCloudAccountSession
113+
PersistedCloudAccountSession,
114+
PersistedWatchProvisionIdentity
114115
} from '../main/ets/services/CloudAccountSessionStore';
115116
import { RemotePageState } from '../main/ets/pages/state/RemotePageState';
116117
import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState';
@@ -427,6 +428,20 @@ class GatedAccountSessionManager extends ScriptedAccountSessionManager {
427428

428429
/** HUKS and preferences are not reachable from a local unit run. */
429430
class InMemoryCloudAccountSessionStore extends CloudAccountSessionStore {
431+
private watchIdentity?: PersistedWatchProvisionIdentity;
432+
async loadWatchProvisionIdentity(relayUrl: string, userId: string,
433+
deviceId: string): Promise<PersistedWatchProvisionIdentity | undefined> {
434+
// A persisted key keeps this refusal test independent of HUKS/randomness
435+
// (neither platform service exists in the host LocalTest runner).
436+
return this.watchIdentity || {
437+
relayUrl, userId, deviceId, requestId: '12345678-1234-4234-8234-123456789abc',
438+
privateKeyBase64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
439+
};
440+
}
441+
async saveWatchProvisionIdentity(identity: PersistedWatchProvisionIdentity): Promise<void> {
442+
this.watchIdentity = identity;
443+
}
444+
430445
readonly saved: PersistedCloudAccountSession[] = [];
431446
profile?: GitHubAccountProfile;
432447
async loadProfile(_userId: string): Promise<GitHubAccountProfile | undefined> { return this.profile; }
@@ -1319,7 +1334,8 @@ export default function transportAndGeneralChatUnitTest() {
13191334
'12345678-1234-4234-8234-123456789abc'
13201335
);
13211336
} catch (err) {
1322-
if (err instanceof CloudAccountRequestError) statusCode = err.statusCode;
1337+
if (!(err instanceof CloudAccountRequestError)) throw new Error(String(err));
1338+
statusCode = err.statusCode;
13231339
}
13241340
expect(statusCode).assertEqual(expectedStatus);
13251341
expect(harness.sessionStore.clears).assertEqual(0);

‎src/apps/mobile/harmonyos/tools/tests/interaction-mailbox.test.cjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const models = load('../model/InteractionMailbox');
1515
const { InteractionMailboxStore } = load('InteractionMailboxStore', { '../model/InteractionMailbox': models });
1616
const reducer = load('DurableSessionReducer');
1717
const { ChatSessionController } = load('ChatSessionController', {
18+
'./MessageQueueStore': load('MessageQueueStore', { '../model/MessageQueue': load('../model/MessageQueue') }),
1819
'./InteractionMailboxStore': { InteractionMailboxStore }, './DurableSessionReducer': reducer,
1920
'./SubagentStreamCoordinator': load('SubagentStreamCoordinator', { './DurableSessionReducer': reducer }),
2021
});
@@ -25,6 +26,7 @@ const snapshot = (requests = [request()], questions = [], sessionId = 's') => ({
2526
function fixture() {
2627
const f = { state: null, wire: snapshot(), calls: [], fail: false };
2728
f.client = {
29+
supportsMessageQueue: () => false,
2830
async getModelCatalog() { return { version: 1, models: [], default_models: {} }; },
2931
async hostInvoke(command, args) {
3032
f.calls.push({ command, args });
@@ -67,6 +69,7 @@ test('tool completion cannot consume runtime approval or manufacture an active t
6769
assert.deepEqual(mailbox.requests.map(r => r.requestId), ['approval']);
6870
assert.equal(transcript.activeTurn, undefined);
6971
assert.deepEqual(transcript.messageSnapshot, []);
72+
controller.stop();
7073
f.wire = snapshot([]);
7174
await callbacks.onEvent({ session_id: 's', event: 'session-interaction-changed', payload: {} });
7275
assert.deepEqual(mailbox.requests, []);

‎src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ const assert = require('node:assert/strict');
33
const fs = require('node:fs');
44
const path = require('node:path');
55
const ts = require('typescript');
6+
function load(name, dependencies = {}) {
7+
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets', `${name}.ets`), 'utf8');
8+
const exports = {};
9+
new Function('require', 'exports', ts.transpileModule(source, { compilerOptions: {
10+
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
11+
} }).outputText)(key => dependencies[key] || {}, exports);
12+
return exports;
13+
}
14+
const queueModule = load('services/MessageQueueStore', { '../model/MessageQueue': load('model/MessageQueue') });
615
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services/ChatSessionController.ets'), 'utf8');
716
const exported = {};
817
new Function('require', 'exports', ts.transpileModule(source, {compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}}).outputText)(name => ({
18+
'./MessageQueueStore': queueModule,
919
'./InteractionMailboxStore': { InteractionMailboxStore: class { select() {} } },
10-
'./DurableSessionReducer': { DurableSessionReducer: class { messages() { return []; } } },
20+
'./DurableSessionReducer': load('services/DurableSessionReducer'),
1121
'./SubagentStreamCoordinator': { SubagentStreamCoordinator: class {
1222
beginSession() {}
1323
endSession() {}
@@ -20,6 +30,7 @@ const flush = () => new Promise(resolve => setImmediate(resolve));
2030
function fixture() {
2131
const requests = [], streams = [], snapshots = [], errors = [];
2232
const manager = {
33+
supportsMessageQueue: () => false,
2334
getModelCatalog(sessionId) { return new Promise((resolve, reject) => requests.push({sessionId, resolve, reject})); },
2435
subscribeSession(sessionId, callbacks) { streams.push(callbacks); return {close() {}, isClosed() {return false;}}; },
2536
};

‎src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ const assert = require('node:assert/strict');
33
const fs = require('node:fs');
44
const path = require('node:path');
55
const ts = require('typescript');
6+
const activeControllers = new Set();
7+
test.afterEach(() => {
8+
for (const controller of activeControllers) controller.stop();
9+
activeControllers.clear();
10+
});
11+
function createController(Controller, manager, hooks) {
12+
// These transcript fixtures model hosts without a message queue. Leaving
13+
// the capability method absent starts error/retry timers in the real store.
14+
const controller = new Controller(Object.assign(manager, { supportsMessageQueue: () => false }), hooks);
15+
activeControllers.add(controller);
16+
return controller;
17+
}
618
function load(name, dependencies = {}) {
719
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services', `${name}.ets`), 'utf8');
820
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText;
@@ -18,6 +30,7 @@ const { DurableSessionReducer } = reducerModule;
1830
const subagentModule = load('SubagentStreamCoordinator', { './DurableSessionReducer': reducerModule });
1931
function loadController() {
2032
return load('ChatSessionController', {
33+
'./MessageQueueStore': load('MessageQueueStore', { '../model/MessageQueue': load('../model/MessageQueue') }),
2134
'./DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule,
2235
'./SubagentStreamCoordinator': subagentModule
2336
});
@@ -33,7 +46,7 @@ function suggestionFixture() {
3346
const snapshots = [];
3447
const manager = { getModelCatalog: async () => ({version: 1, models: [], default_models: {}}),
3548
subscribeSession: (_id, next) => { callbacks = next; return {wake() {}, close() {}}; } };
36-
const controller = new ChatSessionController(manager, {onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => {throw error;}});
49+
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), {onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => {throw error;}});
3750
controller.start('session', {pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0});
3851
return { controller, snapshots, stream: () => callbacks, latest: () => snapshots.at(-1) };
3952
}
@@ -126,7 +139,7 @@ test('controller hydrates from the same record log without transcript RPC', asyn
126139
const { ChatSessionController } = loadController();
127140
let callbacks, snapshots = [];
128141
const manager = { getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), subscribeSession: (_id, next) => { callbacks = next; return { wake() {}, close() {} }; }, getSessionMessages: () => { throw new Error('Snapshot RPC is forbidden'); } };
129-
const controller = new ChatSessionController(manager, { onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => { throw error; } });
142+
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), { onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => { throw error; } });
130143
controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 });
131144
await callbacks.onEvent(record(1, 'partial'));
132145
assert.equal(snapshots.length, 0);
@@ -180,7 +193,7 @@ test('initial and resumed mailbox restores questions independently of transcript
180193
return {sessionId:'session',permissions:{revision:0,requests:[]},userQuestions:{revision:1,questions:[{toolId:'question-tool',sessionId:'session',questions:question,registeredAtMs:1}]}};
181194
}
182195
};
183-
const controller = new ChatSessionController(manager,{onSnapshot:value=>snapshot=value,onMailbox:value=>mailbox=value,onError:error=>{throw error;},canPoll:()=>true});
196+
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }),{onSnapshot:value=>snapshot=value,onMailbox:value=>mailbox=value,onError:error=>{throw error;},canPoll:()=>true});
184197
controller.start('session',{pollVersion:0,knownMessageCount:0,knownModelCatalogVersion:0});
185198
await callbacks.onResumed(); await callbacks.onCaughtUp();
186199
assert.equal(snapshot.activeTurn,undefined);
@@ -321,7 +334,7 @@ test('history publishes user and assistant together after reduction, including a
321334
getModelCatalog: () => new Promise(resolve => { resolveCatalog = resolve; }),
322335
subscribeSession: (_id, next) => { callbacks = next; return { close() {} }; }
323336
};
324-
const controller = new ChatSessionController(manager, { canPoll: () => true,
337+
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), { canPoll: () => true,
325338
onSnapshot: value => snapshots.push(value), onError: error => { throw error; } });
326339
controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 });
327340
await callbacks.onCaughtUp();
@@ -355,7 +368,7 @@ async function durableTimelineHarness() {
355368
timeline.reset('session');
356369
let stream;
357370
const { ChatSessionController } = loadController();
358-
const controller = new ChatSessionController({
371+
const controller = createController(ChatSessionController, {
359372
getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }),
360373
subscribeSession: (_id, callbacks) => { stream = callbacks; return { wake() {}, close() {} }; }
361374
}, { onSnapshot: snapshot => timeline.applySnapshot(snapshot), canPoll: () => true,
@@ -455,7 +468,7 @@ test('host restart terminal record settles active turn without reporting success
455468
const { ChatSessionController } = loadController();
456469
let stream, snapshot;
457470
const timeline = new ChatTimelineStore(); timeline.reset('session');
458-
const controller = new ChatSessionController({ getModelCatalog: async () => ({version:1,models:[],default_models:{}}),
471+
const controller = createController(ChatSessionController, { getModelCatalog: async () => ({version:1,models:[],default_models:{}}),
459472
subscribeSession: (_id, callbacks) => { stream=callbacks; return {wake(){},close(){}}; }
460473
}, { onSnapshot: value => { snapshot=value; timeline.applySnapshot(value); }, canPoll:()=>true, onError:error=>{throw error;} });
461474
controller.start('session',{pollVersion:0,knownMessageCount:0,knownModelCatalogVersion:0});

‎src/apps/mobile/harmonyos/tools/tests/workspace-editor.test.cjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ test('sidebar final entry hierarchy keeps modes in workspace plus and tools in t
2020
assert.doesNotMatch(menu,/supportsHarnessProfiles|workspaceTools\.files|workspaceTools\.terminal|openWorkspaceTools/);
2121
const sidebar=fs.readFileSync(path.join(components,'AppSidebar.ets'),'utf8');
2222
assert.doesNotMatch(sidebar,/requestChat|sidebar\.newChat|sidebar\.conversations/);
23-
assert.match(sidebar,/workspaceTools\.title/);
23+
const footer=fs.readFileSync(path.join(components,'SidebarToolsFooter.ets'),'utf8');
24+
assert.match(sidebar,/SidebarToolsFooter\(\{/);
25+
assert.match(footer,/workspaceTools\.title/);
2426
assert.match(sidebar,/if \(this.connectionState !== 'connected'\) return/);
2527
assert.doesNotMatch(sidebar,/DeviceToolsPicker|showToolsPicker/);
2628
assert.match(sidebar,/type: 'open-device', deviceId: this.controlTargetDeviceId/);

‎src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,26 @@ public data class RemoteSession public constructor(
4949
public val workspaceName: String?,
5050
public val workspaceIdentity: RemoteWorkspaceIdentity?,
5151
/** Set when the session hangs off another one (btw/review/miniapp/subagent). */
52-
public val parentSessionId: String? = null,
53-
public val relationshipKind: String? = null,
52+
public val parentSessionId: String?,
53+
public val relationshipKind: String?,
5454
) {
55+
// Explicit overloads keep legacy callers working on both Kotlin and Swift.
56+
// Kotlin default arguments are not exported as Swift initializers.
5557
public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
5658
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?) :
5759
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName, null)
60+
61+
public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
62+
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?,
63+
workspaceIdentity: RemoteWorkspaceIdentity?) :
64+
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName,
65+
workspaceIdentity, null, null)
66+
67+
public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
68+
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?,
69+
workspaceIdentity: RemoteWorkspaceIdentity?, parentSessionId: String?) :
70+
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName,
71+
workspaceIdentity, parentSessionId, null)
5872
}
5973

6074
public data class ChatSessionCursor public constructor(

0 commit comments

Comments
 (0)