feat(plugin-sdk,stage-tamagotchi): demo plugin, and plugin inspector

This commit is contained in:
Neko Ayaka
2026-02-16 16:06:28 +08:00
parent cae48f2801
commit e8f6155ec6
16 changed files with 926 additions and 43 deletions
@@ -7,10 +7,9 @@ export interface CapabilityDescriptor {
updatedAt: number
}
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, {
key: string
timeoutMs?: number
}>('proj-airi:plugin-sdk:apis:protocol:capabilities:wait')
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, { key: string, timeoutMs?: number }>(
'proj-airi:plugin-sdk:apis:protocol:capabilities:wait',
)
export const protocolCapabilitySnapshot = defineInvokeEventa<CapabilityDescriptor[]>(
'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot',
@@ -0,0 +1,413 @@
<script setup lang="ts">
import type {
PluginHostSessionSummary,
PluginManifestSummary,
} from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { Section } from '@proj-airi/stage-ui/components'
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { Button, Callout, Input } from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
const store = usePluginHostInspectorStore()
const filter = ref('')
const selectedPluginName = ref('')
const discoveredPlugins = computed(() => {
const query = filter.value.trim().toLowerCase()
const plugins = store.discoveredPlugins.slice().sort((left, right) => left.name.localeCompare(right.name))
if (!query)
return plugins
return plugins.filter(plugin =>
plugin.name.toLowerCase().includes(query)
|| plugin.path.toLowerCase().includes(query),
)
})
const enabledPlugins = computed(() => {
return discoveredPlugins.value.filter(plugin => plugin.enabled)
})
const loadedPlugins = computed(() => {
return discoveredPlugins.value.filter(plugin => plugin.loaded)
})
const sessionByPluginName = computed(() => {
const map = new Map<string, PluginHostSessionSummary>()
for (const session of store.sessions) {
map.set(session.manifestName, session)
}
return map
})
const readyCapabilitiesCount = computed(() => {
return store.capabilities.filter(capability => capability.state === 'ready').length
})
function chipClasses(theme: 'neutral' | 'emerald' | 'amber') {
if (theme === 'emerald') {
return [
'bg-emerald-100',
'text-emerald-700',
'dark:bg-emerald-900/50',
'dark:text-emerald-300',
'border-emerald-300',
'dark:border-emerald-700',
]
}
if (theme === 'amber') {
return [
'bg-amber-100',
'text-amber-700',
'dark:bg-amber-900/50',
'dark:text-amber-300',
'border-amber-300',
'dark:border-amber-700',
]
}
return [
'bg-neutral-100',
'text-neutral-700',
'dark:bg-neutral-800',
'dark:text-neutral-300',
'border-neutral-300',
'dark:border-neutral-700',
]
}
function phaseChipTheme(phase: string) {
if (phase === 'ready')
return 'emerald'
if (phase === 'failed')
return 'amber'
if (phase === 'loading' || phase === 'authenticating' || phase === 'preparing')
return 'amber'
return 'neutral'
}
async function refresh() {
try {
await store.refreshAll()
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to refresh plugin host debug state.')
}
}
async function loadEnabled() {
try {
await store.loadEnabled()
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load enabled plugins.')
}
}
async function setEnabled(plugin: PluginManifestSummary, enabled: boolean) {
try {
await store.setEnabled({
name: plugin.name,
enabled,
path: plugin.path,
})
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to update enabled state for ${plugin.name}.`)
}
}
async function loadPlugin(plugin: PluginManifestSummary) {
try {
await store.load({ name: plugin.name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${plugin.name}.`)
}
}
async function unloadPlugin(plugin: PluginManifestSummary) {
try {
await store.unload({ name: plugin.name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to unload plugin ${plugin.name}.`)
}
}
async function loadSelectedPlugin() {
const name = selectedPluginName.value.trim()
if (!name) {
toast.error('Enter a plugin name to load.')
return
}
try {
await store.load({ name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${name}.`)
}
}
onMounted(async () => {
await refresh()
})
</script>
<template>
<div :class="['h-full', 'flex', 'flex-col', 'gap-4', 'overflow-y-auto', 'p-4']">
<Callout
v-if="!store.isAvailable"
theme="orange"
label="Plugin host debug is unavailable in this runtime."
description="Open this page from Stage Tamagotchi renderer to use Electron plugin host controls."
/>
<Callout
v-if="store.error"
theme="orange"
label="Last Error"
:description="store.error"
/>
<div :class="['grid', 'gap-2', 'sm:grid-cols-2', 'xl:grid-cols-4']">
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
<div :class="['text-xs', 'uppercase', 'opacity-70']">
Discovered
</div>
<div :class="['text-2xl', 'font-semibold']">
{{ store.discoveredPlugins.length }}
</div>
</div>
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
<div :class="['text-xs', 'uppercase', 'opacity-70']">
Enabled
</div>
<div :class="['text-2xl', 'font-semibold']">
{{ store.enabledPlugins.length }}
</div>
</div>
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
<div :class="['text-xs', 'uppercase', 'opacity-70']">
Loaded
</div>
<div :class="['text-2xl', 'font-semibold']">
{{ store.loadedPlugins.length }}
</div>
</div>
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
<div :class="['text-xs', 'uppercase', 'opacity-70']">
Capabilities
</div>
<div :class="['text-2xl', 'font-semibold']">
{{ readyCapabilitiesCount }} / {{ store.capabilities.length }}
</div>
</div>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<Input
v-model="filter"
placeholder="Filter discovered plugins..."
class="max-w-[440px] min-w-[280px]"
/>
<Button
label="Refresh"
icon="i-solar:refresh-bold-duotone"
size="sm"
:loading="store.loading"
@click="refresh"
/>
<Button
label="Load Enabled"
icon="i-solar:play-bold-duotone"
size="sm"
:loading="store.loading"
@click="loadEnabled"
/>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<Input
v-model="selectedPluginName"
placeholder="Load discovered plugin by exact name..."
class="max-w-[520px] min-w-[320px]"
/>
<Button
label="Load Plugin"
icon="i-solar:download-minimalistic-bold-duotone"
size="sm"
:disabled="!selectedPluginName.trim()"
:loading="store.loading"
@click="loadSelectedPlugin"
/>
</div>
<Section
title="Discovered Plugins"
icon="i-solar:list-check-bold-duotone"
inner-class="gap-3"
>
<div
v-if="discoveredPlugins.length === 0"
:class="['rounded-xl', 'border', 'border-dashed', 'border-neutral-400/50', 'p-4', 'text-sm', 'opacity-70']"
>
No discovered plugin manifests found.
</div>
<div v-else :class="['grid', 'gap-3']">
<div
v-for="plugin in discoveredPlugins"
:key="plugin.path"
:class="['rounded-xl', 'border', 'border-neutral-300', 'bg-white/70', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950/60']"
>
<div :class="['flex', 'flex-wrap', 'items-center', 'justify-between', 'gap-2']">
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<div :class="['font-semibold']">
{{ plugin.name }}
</div>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(plugin.enabled ? 'emerald' : 'neutral')]">
{{ plugin.enabled ? 'enabled' : 'disabled' }}
</span>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(plugin.loaded ? 'emerald' : 'neutral')]">
{{ plugin.loaded ? 'loaded' : 'not loaded' }}
</span>
<span v-if="plugin.isNew" :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses('amber')]">
new
</span>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<Button
size="sm"
variant="secondary"
:label="plugin.enabled ? 'Disable' : 'Enable'"
:icon="plugin.enabled ? 'i-solar:lock-keyhole-minimalistic-unlocked-bold-duotone' : 'i-solar:lock-keyhole-bold-duotone'"
:loading="store.loading"
@click="setEnabled(plugin, !plugin.enabled)"
/>
<Button
size="sm"
variant="secondary"
label="Load"
icon="i-solar:play-bold-duotone"
:disabled="plugin.loaded"
:loading="store.loading"
@click="loadPlugin(plugin)"
/>
<Button
size="sm"
variant="ghost"
label="Unload"
icon="i-solar:stop-bold-duotone"
:disabled="!plugin.loaded"
:loading="store.loading"
@click="unloadPlugin(plugin)"
/>
</div>
</div>
<div :class="['mt-2', 'text-xs', 'opacity-70', 'font-mono', 'break-all']">
{{ plugin.path }}
</div>
<div :class="['mt-2', 'text-xs', 'opacity-70']">
entrypoints: {{ JSON.stringify(plugin.entrypoints) }}
</div>
<div
v-if="sessionByPluginName.get(plugin.name)"
:class="['mt-2', 'flex', 'items-center', 'gap-2', 'text-sm']"
>
<span>phase:</span>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(phaseChipTheme(sessionByPluginName.get(plugin.name)!.phase))]">
{{ sessionByPluginName.get(plugin.name)!.phase }}
</span>
<span :class="['opacity-70', 'font-mono']">{{ sessionByPluginName.get(plugin.name)!.moduleId }}</span>
</div>
</div>
</div>
</Section>
<Section
title="Enabled Plugins"
icon="i-solar:check-circle-bold-duotone"
inner-class="gap-2"
>
<div :class="['text-sm', 'opacity-80']">
{{ enabledPlugins.length }} plugin(s) enabled in registry.
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
<span
v-for="plugin in enabledPlugins"
:key="`enabled-${plugin.path}`"
:class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses('emerald')]"
>
{{ plugin.name }}
</span>
</div>
</Section>
<Section
title="Loaded Plugins"
icon="i-solar:play-circle-bold-duotone"
inner-class="gap-2"
>
<div :class="['text-sm', 'opacity-80']">
{{ loadedPlugins.length }} plugin(s) currently loaded in host sessions.
</div>
<div :class="['grid', 'gap-2']">
<div
v-for="plugin in loadedPlugins"
:key="`loaded-${plugin.path}`"
:class="['rounded-lg', 'bg-neutral-100', 'p-2', 'dark:bg-neutral-900/70']"
>
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
<span :class="['font-semibold']">{{ plugin.name }}</span>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(phaseChipTheme(sessionByPluginName.get(plugin.name)?.phase ?? 'unknown'))]">
{{ sessionByPluginName.get(plugin.name)?.phase ?? 'unknown' }}
</span>
</div>
</div>
</div>
</Section>
<Section
title="Capabilities"
icon="i-solar:widget-2-bold-duotone"
inner-class="gap-2"
>
<div
v-if="store.capabilities.length === 0"
:class="['text-sm', 'opacity-70']"
>
No capabilities announced.
</div>
<div v-else :class="['grid', 'gap-2']">
<div
v-for="capability in store.capabilities"
:key="capability.key"
:class="['rounded-lg', 'border', 'border-neutral-300', 'bg-white/60', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950/60']"
>
<div :class="['flex', 'flex-wrap', 'items-center', 'justify-between', 'gap-2']">
<span :class="['font-mono', 'text-xs', 'sm:text-sm']">{{ capability.key }}</span>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(capability.state === 'ready' ? 'emerald' : 'amber')]">
{{ capability.state }}
</span>
</div>
<div :class="['mt-2', 'text-xs', 'opacity-70']">
updated: {{ new Date(capability.updatedAt).toLocaleString() }}
</div>
<pre :class="['mt-2', 'overflow-auto', 'rounded-lg', 'bg-neutral-100', 'p-2', 'text-xs', 'dark:bg-neutral-900/70']">{{ JSON.stringify(capability.metadata ?? {}, null, 2) }}</pre>
</div>
</div>
</Section>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
title: Plugin Host Debug
subtitleKey: tamagotchi.settings.devtools.title
</route>
+29 -12
View File
@@ -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()
})
@@ -22,10 +22,16 @@ export interface CharacterSparkNotifyReaction {
interface StreamingReactionState {
reaction: CharacterSparkNotifyReaction
intent: IntentHandle
parser: ReturnType<typeof useLlmmarkerParser>
parser: ReturnType<ParserFactory>
}
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)
@@ -0,0 +1,190 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface PluginManifestSummary {
name: string
entrypoints: Record<string, string | undefined>
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<string, unknown>
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<PluginRegistrySnapshot>
setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
loadEnabled: () => Promise<PluginRegistrySnapshot>
load: (payload: { name: string }) => Promise<PluginRegistrySnapshot>
unload: (payload: { name: string }) => Promise<PluginRegistrySnapshot>
inspect: () => Promise<PluginHostDebugSnapshot>
}
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<PluginHostDebugBridge>()
const registry = ref<PluginRegistrySnapshot>()
const sessions = ref<PluginHostSessionSummary[]>([])
const capabilities = ref<PluginCapabilityState[]>([])
const refreshedAt = ref<number>()
const error = ref<string>()
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<T>(run: (activeBridge: PluginHostDebugBridge) => Promise<T>) {
// 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,
}
})
@@ -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)
})
@@ -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<ArrayBuffer>,