合并原生壳桥接统一分支
合并 Expo 移动壳与 Tauri 桌面壳工程 保留创作主页、项目入口和现有生产发布脚本 同步 HostBridge 文档、依赖锁文件和原生壳门禁
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -54,22 +54,27 @@ if (failures.length > 0) {
|
||||
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
|
||||
|
||||
function checkMiniProgramShell() {
|
||||
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
|
||||
const shellPath = join(repoRoot, 'miniprogram', 'shell', 'webView.js');
|
||||
const webViewBridgePath = join(repoRoot, 'miniprogram', 'host-bridge', 'webView.js');
|
||||
const pagePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
|
||||
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
|
||||
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
|
||||
|
||||
ensureNeedles(pagePath, ["require('../../shell/webView')"]);
|
||||
ensureNeedles(shellPath, [
|
||||
'/api/auth/wechat/miniprogram-login',
|
||||
'/api/auth/wechat/bind-phone',
|
||||
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
|
||||
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
|
||||
'auth_provider',
|
||||
'auth_token',
|
||||
'auth_binding_status',
|
||||
'bindingStatus',
|
||||
'pending_bind_phone',
|
||||
'wechatPhoneCode',
|
||||
]);
|
||||
ensureNeedles(webViewBridgePath, [
|
||||
'auth_provider',
|
||||
'auth_token',
|
||||
'auth_binding_status',
|
||||
]);
|
||||
|
||||
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
|
||||
|
||||
|
||||
@@ -479,6 +479,7 @@ export async function findAvailablePort({
|
||||
reservedPorts = new Set(),
|
||||
maxAttempts = null,
|
||||
portRange = null,
|
||||
strict = false,
|
||||
}) {
|
||||
const range = normalizePortRange(portRange);
|
||||
const startPort = normalizePort(preferredPort, 0);
|
||||
@@ -501,6 +502,18 @@ export async function findAvailablePort({
|
||||
throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`);
|
||||
}
|
||||
|
||||
if (strict && startPort !== 0) {
|
||||
if (reservedPorts.has(startPort)) {
|
||||
throw new Error(`端口 ${host}:${startPort} 已被当前 dev 启动流程占用,无法严格使用该端口`);
|
||||
}
|
||||
|
||||
if (await isPortAvailable({host, port: startPort})) {
|
||||
return startPort;
|
||||
}
|
||||
|
||||
throw new Error(`端口 ${host}:${startPort} 不可用,无法严格使用该端口`);
|
||||
}
|
||||
|
||||
const boundedAttempts = range
|
||||
? Number.isFinite(maxAttempts)
|
||||
? Math.min(Math.max(0, maxAttempts), range.end - startPort)
|
||||
@@ -565,6 +578,7 @@ export async function resolveDevStackPorts(config) {
|
||||
preferredPort: portConfig.preferredPort,
|
||||
reservedPorts,
|
||||
portRange: portConfig.portRange,
|
||||
strict: Boolean(portConfig.strict),
|
||||
});
|
||||
reservedPorts.add(resolvedPort);
|
||||
result[name] = resolvedPort;
|
||||
|
||||
@@ -58,6 +58,23 @@ describe('dev stack port utils', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('严格端口模式在端口被占用时直接失败而不是漂移', async () => {
|
||||
const server = await reservePort(0);
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
findAvailablePort({
|
||||
host: '127.0.0.1',
|
||||
preferredPort: port,
|
||||
strict: true,
|
||||
}),
|
||||
).rejects.toThrow('无法严格使用该端口');
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
it('端口查找不会越过 Linux 用户端口段', async () => {
|
||||
await expect(
|
||||
findAvailablePort({
|
||||
|
||||
+11
-4
@@ -135,6 +135,7 @@ function parseArgs(argv, baseEnv) {
|
||||
migrationBootstrapSecretMode: 'auto',
|
||||
watch: false,
|
||||
interactive: true,
|
||||
strictWebPort: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -170,6 +171,9 @@ function parseArgs(argv, baseEnv) {
|
||||
options.webPort = normalizePort(readValue(), options.webPort);
|
||||
explicitOptions.add('webPort');
|
||||
break;
|
||||
case '--strict-web-port':
|
||||
options.strictWebPort = true;
|
||||
break;
|
||||
case '--admin-web-host':
|
||||
options.adminWebHost = readValue();
|
||||
explicitOptions.add('adminWebHost');
|
||||
@@ -960,13 +964,15 @@ class DevRunner {
|
||||
async resolvePorts(command) {
|
||||
const {options} = this;
|
||||
const portConfig = {};
|
||||
const portRangeFor = (optionName) =>
|
||||
this.explicitOptions.has(optionName) ? null : this.state.portRange;
|
||||
|
||||
if (command === 'all' || command === 'spacetime') {
|
||||
if (!options.skipSpacetime && !this.state.spacetimeReused) {
|
||||
portConfig.spacetime = {
|
||||
host: options.spacetimeHost,
|
||||
preferredPort: options.spacetimePort,
|
||||
portRange: this.state.portRange,
|
||||
portRange: portRangeFor('spacetimePort'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -975,7 +981,7 @@ class DevRunner {
|
||||
portConfig.api = {
|
||||
host: options.apiHost,
|
||||
preferredPort: options.apiPort,
|
||||
portRange: this.state.portRange,
|
||||
portRange: portRangeFor('apiPort'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -983,7 +989,8 @@ class DevRunner {
|
||||
portConfig.web = {
|
||||
host: options.webHost,
|
||||
preferredPort: options.webPort,
|
||||
portRange: this.state.portRange,
|
||||
portRange: portRangeFor('webPort'),
|
||||
strict: options.strictWebPort,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -991,7 +998,7 @@ class DevRunner {
|
||||
portConfig.adminWeb = {
|
||||
host: options.adminWebHost,
|
||||
preferredPort: options.adminWebPort,
|
||||
portRange: this.state.portRange,
|
||||
portRange: portRangeFor('adminWebPort'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,36 @@ describe('dev scheduler argument routing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
linuxTest('Linux 桌面壳显式指定 web-port 时不被系统级端口段改写', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
|
||||
try {
|
||||
const {command, explicitOptions, options} = parseArgs(
|
||||
['web', '--web-port', '3000', '--strict-web-port'],
|
||||
{
|
||||
USER: 'alice',
|
||||
LOGNAME: 'alice',
|
||||
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
|
||||
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
|
||||
},
|
||||
);
|
||||
const runner = new DevRunner(options, {
|
||||
USER: 'alice',
|
||||
LOGNAME: 'alice',
|
||||
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
|
||||
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
|
||||
}, explicitOptions);
|
||||
|
||||
await runner.prepareLinuxPortRange(command);
|
||||
expect(runner.state.portRange.label).toBe('22000-22099');
|
||||
expect(runner.options.webPort).toBe(3000);
|
||||
expect(runner.options.apiPort).toBe(22001);
|
||||
expect(runner.options.spacetimePort).toBe(22002);
|
||||
expect(runner.options.adminWebPort).toBe(22003);
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
|
||||
@@ -5,12 +5,17 @@ import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const pageScriptPath = join(
|
||||
const shellScriptPath = join(
|
||||
repoRoot,
|
||||
'miniprogram',
|
||||
'pages',
|
||||
'web-view',
|
||||
'index.js',
|
||||
'shell',
|
||||
'webView.js',
|
||||
);
|
||||
const webViewHostBridgePath = join(
|
||||
repoRoot,
|
||||
'miniprogram',
|
||||
'host-bridge',
|
||||
'webView.js',
|
||||
);
|
||||
|
||||
type MiniProgramPage = {
|
||||
@@ -21,6 +26,9 @@ type MiniProgramPage = {
|
||||
onShareTimeline: () => Record<string, unknown>;
|
||||
onShow: () => void;
|
||||
consumePayResult: () => void;
|
||||
handleGetPhoneNumber: (event: {
|
||||
detail?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
function createWxMock() {
|
||||
@@ -43,45 +51,28 @@ function loadWebViewPage(
|
||||
wxMock: ReturnType<typeof createWxMock>,
|
||||
configOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
let pageConfig: Record<string, unknown> | null = null;
|
||||
const source = readFileSync(pageScriptPath, 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
getCurrentPages: () => [],
|
||||
module: { exports: {} },
|
||||
Page(config: Record<string, unknown>) {
|
||||
pageConfig = config;
|
||||
(globalThis as unknown as { wx: ReturnType<typeof createWxMock> }).wx =
|
||||
wxMock;
|
||||
const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {});
|
||||
const shellModule = loadCommonJsModule(shellScriptPath, {
|
||||
'../config': {
|
||||
API_BASE_URL: 'https://www.genarrative.world/',
|
||||
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
|
||||
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
|
||||
MINI_PROGRAM_APP_ID: 'wx-test-app',
|
||||
MINI_PROGRAM_ENV: 'release',
|
||||
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
|
||||
WEB_VIEW_SOURCE_QUERY: {
|
||||
clientType: 'mini_program',
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
},
|
||||
...configOverrides,
|
||||
},
|
||||
setTimeout(callback: () => void) {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
require(requestPath: string) {
|
||||
if (requestPath === '../../config') {
|
||||
return {
|
||||
API_BASE_URL: 'https://www.genarrative.world/',
|
||||
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
|
||||
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
|
||||
MINI_PROGRAM_APP_ID: 'wx-test-app',
|
||||
MINI_PROGRAM_ENV: 'release',
|
||||
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
|
||||
WEB_VIEW_SOURCE_QUERY: {
|
||||
clientType: 'mini_program',
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
},
|
||||
...configOverrides,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected require: ${requestPath}`);
|
||||
},
|
||||
wx: wxMock,
|
||||
'../host-bridge/webView': webViewBridge,
|
||||
}) as {
|
||||
createWechatWebViewPage: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, sandbox, { filename: pageScriptPath });
|
||||
|
||||
if (!pageConfig) {
|
||||
throw new Error('web-view page did not call Page()');
|
||||
}
|
||||
const pageConfig = shellModule.createWechatWebViewPage();
|
||||
|
||||
const page = {
|
||||
...pageConfig,
|
||||
@@ -94,9 +85,38 @@ function loadWebViewPage(
|
||||
return page;
|
||||
}
|
||||
|
||||
function loadCommonJsModule(
|
||||
filePath: string,
|
||||
requireMap: Record<string, unknown>,
|
||||
) {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const module = { exports: {} as Record<string, unknown> };
|
||||
const sandbox = {
|
||||
console,
|
||||
getCurrentPages: () => [],
|
||||
module,
|
||||
exports: module.exports,
|
||||
setTimeout(callback: () => void) {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
require(requestPath: string) {
|
||||
if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) {
|
||||
return requireMap[requestPath];
|
||||
}
|
||||
throw new Error(`Unexpected require: ${requestPath}`);
|
||||
},
|
||||
wx: globalThis.wx,
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, sandbox, { filename: filePath });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
describe('mini-program web-view auth page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
test('默认进入时不预登录,直接打开未登录 web-view', async () => {
|
||||
@@ -264,6 +284,10 @@ describe('mini-program web-view auth page', () => {
|
||||
data: {
|
||||
token: 'jwt-pending-wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
user: {
|
||||
displayName: '陶泥玩家',
|
||||
publicUserCode: 'SY-12345678',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -283,4 +307,76 @@ describe('mini-program web-view auth page', () => {
|
||||
expect(page.data.loading).toBe(false);
|
||||
expect(page.data.phoneBindingRequired).toBe(true);
|
||||
});
|
||||
|
||||
test('微信登录失败不向页面透出原生错误', async () => {
|
||||
const wxMock = createWxMock();
|
||||
const loginError = { errMsg: 'login:fail private native detail' };
|
||||
wxMock.login.mockImplementation(({ fail }) => {
|
||||
fail(loginError);
|
||||
});
|
||||
const page = loadWebViewPage(wxMock);
|
||||
|
||||
await page.onLoad({ authAction: 'login', returnTo: 'previous' });
|
||||
|
||||
expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。');
|
||||
expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed');
|
||||
expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed');
|
||||
expect(console.error.mock.calls.flat()).not.toContain(loginError);
|
||||
expect(page.data.phoneBindingRequired).toBe(false);
|
||||
});
|
||||
|
||||
test('绑定手机号失败不向页面透出后端错误体', async () => {
|
||||
const wxMock = createWxMock();
|
||||
const page = loadWebViewPage(wxMock);
|
||||
page.data.authResult = {
|
||||
token: 'jwt-pending-wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
};
|
||||
wxMock.request.mockImplementation(({ success }) => {
|
||||
success({
|
||||
statusCode: 500,
|
||||
data: {
|
||||
error: {
|
||||
message: 'private backend detail',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.handleGetPhoneNumber({
|
||||
detail: {
|
||||
code: 'wechat-phone-code',
|
||||
},
|
||||
});
|
||||
|
||||
expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。');
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[web-view] mini program bind phone failed',
|
||||
);
|
||||
expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed');
|
||||
expect(console.error.mock.calls.flat()).not.toContain('private backend detail');
|
||||
});
|
||||
|
||||
test('拒绝手机号授权不向页面透出微信原生错误', async () => {
|
||||
const wxMock = createWxMock();
|
||||
const page = loadWebViewPage(wxMock);
|
||||
page.data.authResult = {
|
||||
token: 'jwt-pending-wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
};
|
||||
const authDeclined = {
|
||||
errMsg: 'getPhoneNumber:fail private native detail',
|
||||
};
|
||||
|
||||
await page.handleGetPhoneNumber({
|
||||
detail: authDeclined,
|
||||
});
|
||||
|
||||
expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。');
|
||||
expect(page.data.errorMessage).not.toContain('private native detail');
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[web-view] bind phone auth declined',
|
||||
);
|
||||
expect(console.error.mock.calls.flat()).not.toContain(authDeclined);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user