diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md new file mode 100644 index 000000000..89b619116 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md @@ -0,0 +1,30 @@ +# Devtools Sample Plugin + +This sample plugin is for validating plugin host behavior in the **Plugin Host Inspector** page. + +## Files + +- `devtools-sample-plugin.json`: plugin manifest (`ManifestV1`) +- `devtools-sample-plugin.mjs`: plugin implementation + +## How to use + +1. Open `/devtools/plugin-host` in Stage Tamagotchi. +2. Note the `registry.root` path from the page. +3. Copy both files into that `registry.root` directory. +4. In Plugin Host Inspector: + - click `Refresh` + - find `devtools-sample-plugin` + - click `Enable` + - click `Load` (or `Load Enabled`) +5. Confirm: + - plugin appears as `loaded` + - session phase becomes `ready` + - capability list is visible + +## What this plugin does + +- `init`: logs startup in renderer/main console. +- `setupModules`: calls `apis.providers.listProviders()` and logs provider names. + +It does not mutate app state; it is safe for lifecycle verification. diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json new file mode 100644 index 000000000..2742c7368 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json @@ -0,0 +1,8 @@ +{ + "apiVersion": "v1", + "kind": "manifest.plugin.airi.moeru.ai", + "name": "devtools-sample-plugin", + "entrypoints": { + "electron": "./devtools-sample-plugin.mjs" + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs new file mode 100644 index 000000000..14af14850 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.mjs @@ -0,0 +1,22 @@ +function nowIso() { + return new Date().toISOString() +} + +/** + * Example plugin for verifying plugin-host lifecycle in devtools. + * + * This module intentionally avoids external package imports so it can run + * from the userData plugins folder without additional dependency setup. + */ +export async function init(_context) { + console.info('[devtools-sample-plugin] init', { at: nowIso() }) +} + +export async function setupModules({ apis }) { + const providers = await apis.providers.listProviders() + console.info('[devtools-sample-plugin] setupModules', { + at: nowIso(), + providerCount: providers.length, + providerNames: providers.map(provider => provider.name), + }) +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index f43c66c8e..698f5431a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -64,6 +64,19 @@ async function writeManifest(params: { dir: string, name: string, entrypoint: st return path } +async function writeManifestInPluginDir(params: { rootDir: string, pluginDirName: string, pluginName: string, entrypointPath: string }) { + const pluginDir = join(params.rootDir, params.pluginDirName) + await mkdir(pluginDir, { recursive: true }) + const entrypointFile = await copyEntrypoint({ dir: pluginDir, path: params.entrypointPath }) + const manifestPath = await writeManifest({ + dir: pluginDir, + name: params.pluginName, + entrypoint: `./${entrypointFile}`, + }) + + return { pluginDir, manifestPath } +} + async function copyEntrypoint(params: { dir: string, path: string }) { const file = basename(params.path) const destination = join(params.dir, file) @@ -89,14 +102,22 @@ describe('setupPluginHost', () => { vi.clearAllMocks() }) - it('lists manifests from the plugins directory', async () => { + it('lists manifests from plugin subdirectories', async () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint }) - const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint }) - const normalPath = await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile }) - const errorPath = await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile }) + const { manifestPath: normalPath } = await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-normal', + pluginName: 'test-normal', + entrypointPath: normalEntrypoint, + }) + const { manifestPath: errorPath } = await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-error', + pluginName: 'test-error', + entrypointPath: errorEntrypoint, + }) await setupPluginHost() @@ -112,14 +133,55 @@ describe('setupPluginHost', () => { ])) }) + it('ignores root-level manifests and only loads manifests from subdirectories', async () => { + const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') + + const { manifestPath } = await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'devtools-sample-plugin', + pluginName: 'devtools-sample-plugin', + entrypointPath: normalEntrypoint, + }) + const rootEntrypointFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint }) + await writeManifest({ + dir: pluginsDir, + name: 'root-level-plugin', + entrypoint: rootEntrypointFile, + }) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) + const snapshot = await invokeList() + + expect(snapshot.plugins).toEqual([ + expect.objectContaining({ + name: 'devtools-sample-plugin', + path: manifestPath, + enabled: false, + loaded: false, + isNew: true, + }), + ]) + }) + it('loads enabled plugins and keeps failed plugins unloaded', async () => { const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') - const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint }) - const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint }) - await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile }) - await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile }) + await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-normal', + pluginName: 'test-normal', + entrypointPath: normalEntrypoint, + }) + await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-error', + pluginName: 'test-error', + entrypointPath: errorEntrypoint, + }) await setupPluginHost() diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index 528e43735..39b37fd5d 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -1,6 +1,10 @@ import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' -import type { PluginManifestSummary, PluginRegistrySnapshot } from '../../../../shared/eventa' +import type { + PluginHostDebugSnapshot, + PluginManifestSummary, + PluginRegistrySnapshot, +} from '../../../../shared/eventa' import { mkdir, readdir, readFile } from 'node:fs/promises' import { dirname, extname, join } from 'node:path' @@ -13,9 +17,12 @@ import { app, ipcMain } from 'electron' import { array, object, record, safeParse, string } from 'valibot' import { + electronPluginInspect, electronPluginList, + electronPluginLoad, electronPluginLoadEnabled, electronPluginSetEnabled, + electronPluginUnload, electronPluginUpdateCapability, pluginProtocolListProviders, pluginProtocolListProvidersEventName, @@ -69,15 +76,25 @@ async function loadManifestsFrom(dir: string, log: ReturnType): await mkdir(dir, { recursive: true }) const entries = await readdir(dir, { withFileTypes: true }) const manifests: ManifestEntry[] = [] + const manifestPaths: string[] = [] for (const entry of entries) { - if (!entry.isFile()) + if (!entry.isDirectory()) continue - if (extname(entry.name) !== '.json') - continue + const pluginDir = join(dir, entry.name) + const pluginEntries = await readdir(pluginDir, { withFileTypes: true }) + for (const pluginEntry of pluginEntries) { + if (!pluginEntry.isFile()) + continue + if (extname(pluginEntry.name) !== '.json') + continue - const path = join(dir, entry.name) + manifestPaths.push(join(pluginDir, pluginEntry.name)) + } + } + + for (const path of manifestPaths) { try { const raw = await readFile(path, 'utf-8') const parsed = JSON.parse(raw) as unknown @@ -85,6 +102,7 @@ async function loadManifestsFrom(dir: string, log: ReturnType): log.warn('invalid plugin manifest schema', { path }) continue } + manifests.push({ manifest: parsed, path }) } catch (error) { @@ -145,6 +163,7 @@ export async function setupPluginHost(): Promise { let entries = await loadManifestsFrom(pluginsRoot, log) let manifests = entries.map(entry => entry.manifest) const loaded = new Set() + const loadedSessionIds = new Map() const refreshManifests = async () => { entries = await loadManifestsFrom(pluginsRoot, log) @@ -163,6 +182,53 @@ export async function setupPluginHost(): Promise { } } + const toDebugSnapshot = (): PluginHostDebugSnapshot => { + return { + registry: toSnapshot(), + sessions: host.listSessions().map(session => ({ + id: session.id, + manifestName: session.manifest.name, + phase: session.phase, + runtime: session.runtime, + moduleId: session.identity.id, + })), + capabilities: capabilityHost.listCapabilities(), + refreshedAt: Date.now(), + } + } + + const findManifestEntry = (name: string) => { + return entries.find(entry => entry.manifest.name === name) + } + + const loadPluginByName = async (name: string) => { + if (loaded.has(name)) + return + + const entry = findManifestEntry(name) + if (!entry) { + throw new Error(`Plugin manifest not found: ${name}`) + } + + const session = await host.start(entry.manifest, { cwd: dirname(entry.path) }) + loaded.add(name) + loadedSessionIds.set(name, session.id) + log.log('plugin loaded', { plugin: name, sessionId: session.id }) + } + + const unloadPluginByName = (name: string) => { + const sessionId = loadedSessionIds.get(name) + if (!sessionId) { + loaded.delete(name) + return + } + + host.stop(sessionId) + loadedSessionIds.delete(name) + loaded.delete(name) + log.log('plugin unloaded', { plugin: name, sessionId }) + } + const loadEnabled = async () => { const config = getConfig() for (const entry of entries) { @@ -173,9 +239,7 @@ export async function setupPluginHost(): Promise { continue try { - await host.start(entry.manifest, { cwd: dirname(entry.path) }) - loaded.add(name) - log.log('plugin loaded', { plugin: name }) + await loadPluginByName(name) } catch (error) { log.withError(error).withFields({ plugin: name }).error('plugin failed to start') @@ -224,6 +288,22 @@ export async function setupPluginHost(): Promise { return toSnapshot() }) + defineInvokeHandler(context, electronPluginLoad, async (payload) => { + await refreshManifests() + await loadPluginByName(payload.name) + return toSnapshot() + }) + + defineInvokeHandler(context, electronPluginUnload, async (payload) => { + unloadPluginByName(payload.name) + return toSnapshot() + }) + + defineInvokeHandler(context, electronPluginInspect, async () => { + await refreshManifests() + return toDebugSnapshot() + }) + defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => { if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') { capabilityHost.setProvidersListResolver(async () => await invokePluginProtocolListProviders()) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 9c6a5c60e..db0140d48 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -1,10 +1,11 @@ + + + + +meta: + layout: settings + title: Plugin Host Debug + subtitleKey: tamagotchi.settings.devtools.title + diff --git a/packages/stage-ui/src/stores/character.test.ts b/packages/stage-ui/src/stores/character.test.ts index facec5c15..6acbebe07 100644 --- a/packages/stage-ui/src/stores/character.test.ts +++ b/packages/stage-ui/src/stores/character.test.ts @@ -4,8 +4,9 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useCharacterStore } from './character' +import { setCharacterLlmMarkerParserFactoryForTest, useCharacterStore } from './character' import { useAiriCardStore } from './modules' +import { useSpeechRuntimeStore } from './speech-runtime' vi.mock('vue-i18n', () => ({ useI18n: () => ({ @@ -17,6 +18,8 @@ const writeLiteralSpy = vi.fn() const writeFlushSpy = vi.fn() const endSpy = vi.fn() const cancelSpy = vi.fn() +const parserConsumeSpy = vi.fn() +const parserEndSpy = vi.fn() const openSpeechIntentSpy = vi.fn(() => ({ intentId: 'intent-test', @@ -30,22 +33,32 @@ const openSpeechIntentSpy = vi.fn(() => ({ cancel: cancelSpy, })) -vi.mock('../speech-runtime', () => ({ - useSpeechRuntimeStore: () => ({ - openIntent: openSpeechIntentSpy, - }), -})) - describe('store character', () => { beforeEach(() => { const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }) setActivePinia(pinia) + setCharacterLlmMarkerParserFactoryForTest(options => ({ + async consume(textPart: string) { + parserConsumeSpy(textPart) + if (textPart) + await options.onLiteral?.(textPart) + }, + async end() { + parserEndSpy() + }, + })) + writeLiteralSpy.mockClear() writeFlushSpy.mockClear() endSpy.mockClear() cancelSpy.mockClear() openSpeechIntentSpy.mockClear() + parserConsumeSpy.mockClear() + parserEndSpy.mockClear() + + const speechRuntimeStore = useSpeechRuntimeStore(pinia) + speechRuntimeStore.openIntent = openSpeechIntentSpy const airiCardStore = useAiriCardStore(pinia) // @ts-expect-error - testing purpose @@ -92,7 +105,7 @@ describe('store character', () => { expect(store.reactions[199]?.message).toBe('message-200') }) - it('records streamed reactions when the stream ends', () => { + it('records streamed reactions when the stream ends', async () => { const store = useCharacterStore() const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456) @@ -105,10 +118,14 @@ describe('store character', () => { expect(store.reactions[0]?.sourceEventId).toBe('spark-1') expect(store.reactions[0]?.createdAt).toBe(123456) - expect(writeLiteralSpy).toHaveBeenCalledWith('Hello') - expect(writeLiteralSpy).toHaveBeenCalledWith(' world') - expect(writeFlushSpy).toHaveBeenCalled() - expect(endSpy).toHaveBeenCalled() + await vi.waitFor(() => { + expect(parserConsumeSpy).toHaveBeenCalled() + expect(parserEndSpy).toHaveBeenCalled() + expect(writeLiteralSpy).toHaveBeenCalledWith('Hello') + expect(writeLiteralSpy).toHaveBeenCalledWith(' world') + expect(writeFlushSpy).toHaveBeenCalled() + expect(endSpy).toHaveBeenCalled() + }) nowSpy.mockRestore() }) diff --git a/packages/stage-ui/src/stores/character/index.ts b/packages/stage-ui/src/stores/character/index.ts index b426ad539..b6f34c3e2 100644 --- a/packages/stage-ui/src/stores/character/index.ts +++ b/packages/stage-ui/src/stores/character/index.ts @@ -22,10 +22,16 @@ export interface CharacterSparkNotifyReaction { interface StreamingReactionState { reaction: CharacterSparkNotifyReaction intent: IntentHandle - parser: ReturnType + parser: ReturnType } const MAX_REACTIONS = 200 +type ParserFactory = typeof useLlmmarkerParser +let parserFactory: ParserFactory = useLlmmarkerParser + +export function setCharacterLlmMarkerParserFactoryForTest(factory: ParserFactory | null) { + parserFactory = factory ?? useLlmmarkerParser +} export const useCharacterStore = defineStore('character', () => { const { activeCard, systemPrompt } = storeToRefs(useAiriCardStore()) @@ -44,7 +50,7 @@ export const useCharacterStore = defineStore('character', () => { behavior: 'queue', }) - const parser = useLlmmarkerParser({ + const parser = parserFactory({ onLiteral: async (literal) => { if (literal) intent.writeLiteral(literal) @@ -79,7 +85,7 @@ export const useCharacterStore = defineStore('character', () => { behavior: 'interrupt', }) - const parser = useLlmmarkerParser({ + const parser = parserFactory({ onLiteral: async (literal) => { if (literal) intent.writeLiteral(literal) diff --git a/packages/stage-ui/src/stores/devtools/plugin-host-debug.ts b/packages/stage-ui/src/stores/devtools/plugin-host-debug.ts new file mode 100644 index 000000000..b6d353e81 --- /dev/null +++ b/packages/stage-ui/src/stores/devtools/plugin-host-debug.ts @@ -0,0 +1,190 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export interface PluginManifestSummary { + name: string + entrypoints: Record + path: string + enabled: boolean + loaded: boolean + isNew: boolean +} + +export interface PluginRegistrySnapshot { + root: string + plugins: PluginManifestSummary[] +} + +export interface PluginCapabilityState { + key: string + state: 'announced' | 'ready' + metadata?: Record + updatedAt: number +} + +export interface PluginHostSessionSummary { + id: string + manifestName: string + phase: string + runtime: 'electron' | 'node' | 'web' + moduleId: string +} + +export interface PluginHostDebugSnapshot { + registry: PluginRegistrySnapshot + sessions: PluginHostSessionSummary[] + capabilities: PluginCapabilityState[] + refreshedAt: number +} + +interface PluginHostDebugBridge { + list: () => Promise + setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise + loadEnabled: () => Promise + load: (payload: { name: string }) => Promise + unload: (payload: { name: string }) => Promise + inspect: () => Promise +} + +export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-debug', () => { + // Runtime bridge injected by the renderer host (Electron). + // + // Why this exists: + // - `stage-pages` is shared by web + desktop. + // - Plugin-host IPC only exists in desktop (stage-tamagotchi main process). + // - This store keeps UI code shared, and receives runtime-specific operations via `setBridge(...)`. + // + // In web/non-electron runtimes, bridge stays undefined and debug actions fail with a clear message. + const bridge = ref() + const registry = ref() + const sessions = ref([]) + const capabilities = ref([]) + const refreshedAt = ref() + const error = ref() + const loading = ref(false) + + const discoveredPlugins = computed(() => registry.value?.plugins ?? []) + const enabledPlugins = computed(() => discoveredPlugins.value.filter(plugin => plugin.enabled)) + const loadedPlugins = computed(() => discoveredPlugins.value.filter(plugin => plugin.loaded)) + const isAvailable = computed(() => Boolean(bridge.value)) + + function setBridge(nextBridge: PluginHostDebugBridge) { + // Called by renderer bootstrap once Eventa invoke functions are available. + // This turns the shared debug page "online" without coupling it to electron-only imports. + bridge.value = nextBridge + } + + function clearError() { + error.value = undefined + } + + function assignRegistry(nextRegistry: PluginRegistrySnapshot) { + registry.value = nextRegistry + } + + function assignInspection(snapshot: PluginHostDebugSnapshot) { + assignRegistry(snapshot.registry) + sessions.value = snapshot.sessions + capabilities.value = snapshot.capabilities + refreshedAt.value = snapshot.refreshedAt + } + + async function withBridge(run: (activeBridge: PluginHostDebugBridge) => Promise) { + // Single guard/flow wrapper for every debug action. + // + // What it does: + // 1) Runtime gate: blocks actions until bridge is registered. + // 2) Loading lifecycle: toggles `loading` in a centralized place. + // 3) Error normalization: stores user-facing error text for the debug page. + // + // Why debug store needs this: + // - Debug actions are async IPC calls and may fail for runtime/setup reasons. + // - A shared wrapper avoids duplicated try/catch/loading logic across each action. + // - It gives deterministic UI behavior (same errors/spinner semantics for all commands). + if (!bridge.value) { + const message = 'Plugin host debug bridge is not available in this runtime.' + error.value = message + throw new Error(message) + } + + loading.value = true + clearError() + try { + return await run(bridge.value) + } + catch (cause) { + error.value = cause instanceof Error ? cause.message : 'Plugin host debug request failed.' + throw cause + } + finally { + loading.value = false + } + } + + async function refreshRegistry() { + const nextRegistry = await withBridge(activeBridge => activeBridge.list()) + assignRegistry(nextRegistry) + return nextRegistry + } + + async function refreshInspection() { + const snapshot = await withBridge(activeBridge => activeBridge.inspect()) + assignInspection(snapshot) + return snapshot + } + + async function refreshAll() { + return refreshInspection() + } + + async function setEnabled(payload: { name: string, enabled: boolean, path?: string }) { + const nextRegistry = await withBridge(activeBridge => activeBridge.setEnabled(payload)) + assignRegistry(nextRegistry) + await refreshInspection() + return nextRegistry + } + + async function loadEnabled() { + const nextRegistry = await withBridge(activeBridge => activeBridge.loadEnabled()) + assignRegistry(nextRegistry) + await refreshInspection() + return nextRegistry + } + + async function load(payload: { name: string }) { + const nextRegistry = await withBridge(activeBridge => activeBridge.load(payload)) + assignRegistry(nextRegistry) + await refreshInspection() + return nextRegistry + } + + async function unload(payload: { name: string }) { + const nextRegistry = await withBridge(activeBridge => activeBridge.unload(payload)) + assignRegistry(nextRegistry) + await refreshInspection() + return nextRegistry + } + + return { + registry, + sessions, + capabilities, + refreshedAt, + loading, + error, + discoveredPlugins, + enabledPlugins, + loadedPlugins, + isAvailable, + + setBridge, + clearError, + refreshRegistry, + refreshInspection, + refreshAll, + setEnabled, + loadEnabled, + load, + unload, + } +}) diff --git a/packages/stage-ui/src/stores/providers/aliyun/token.test.ts b/packages/stage-ui/src/stores/providers/aliyun/token.test.ts index 067fdffe7..4572f5cb2 100644 --- a/packages/stage-ui/src/stores/providers/aliyun/token.test.ts +++ b/packages/stage-ui/src/stores/providers/aliyun/token.test.ts @@ -26,8 +26,8 @@ describe('buildCreateTokenRequest', () => { } const expectedCanonicalQuery = 'AccessKeyId=my_access_key_id&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=b924c8c3-6d03-4c5d-ad36-d984d3116788&SignatureVersion=1.0&Timestamp=2019-04-18T08%3A32%3A31Z&Version=2019-02-28' - const expectedBuiltQueryString = 'GET&%2F&AccessKeyId%3Dmy_access_key_id%26Action%3DCreateToken%26Format%3DJSON%26RegionId%3Dcn-shanghai%26SignatureMethod%3DHMAC-SHA1%26SignatureNonce%3Db924c8c3-6d03-4c5d-ad36-d984d3116788%26SignatureVersion%3D1.0%26Timestamp%3D2019-04-18T08%253A32%253A31Z%26Version%3D2019-02-28' - const expectedSignature = 'hHq4yNsPitlfDJ2L0nQPdugdEzM=' + const expectedBuiltQueryString = 'POST&%2F&AccessKeyId%3Dmy_access_key_id%26Action%3DCreateToken%26Format%3DJSON%26RegionId%3Dcn-shanghai%26SignatureMethod%3DHMAC-SHA1%26SignatureNonce%3Db924c8c3-6d03-4c5d-ad36-d984d3116788%26SignatureVersion%3D1.0%26Timestamp%3D2019-04-18T08%253A32%253A31Z%26Version%3D2019-02-28' + const expectedSignature = 'X4/yeE8FUchC5Wv7AZJybEuDWzw=' const expectedSignatureEncoded = encodeURIComponent(expectedSignature) const expectedSignedQuery = `Signature=${expectedSignatureEncoded}&${expectedCanonicalQuery}` const expectedUrl = `http://nls-meta.cn-shanghai.aliyuncs.com/?${expectedSignedQuery}` @@ -39,7 +39,7 @@ describe('buildCreateTokenRequest', () => { it('creates the expected string to sign', () => { const canonical = canonicalizeQuery(testParameters) - const stringToSign = createStringToSign('GET', '/', canonical) + const stringToSign = createStringToSign('POST', '/', canonical) expect(stringToSign).toBe(expectedBuiltQueryString) }) diff --git a/packages/stage-ui/src/stores/providers/aliyun/token.ts b/packages/stage-ui/src/stores/providers/aliyun/token.ts index c803df75d..1047abfea 100644 --- a/packages/stage-ui/src/stores/providers/aliyun/token.ts +++ b/packages/stage-ui/src/stores/providers/aliyun/token.ts @@ -56,6 +56,7 @@ export async function signStringToBase64(stringToSign: string, accessKeySecret: name: 'HMAC', hash: { name: 'SHA-1' }, } + const cryptoKey = await subtle.importKey( 'raw', keyData as Uint8Array,