Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wallet): add advanced and automated mode to upgrades #298

Merged
merged 8 commits into from
Jul 29, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { VueWrapper } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { ChangeCanisterFormValue } from '~/components/change-canister/change-canister.types';
import { mount } from '~/test.utils';
import { ChangeCanisterTargetType } from '~/types/station.types';
import AdvancedUpdateMode from './AdvancedUpdateMode.vue';

describe('AdvancedUpdateMode', () => {
it('renders with empty form', () => {
const wrapper = mount(AdvancedUpdateMode, {
props: {
modelValue: {
wasmInitArg: undefined,
target: undefined,
wasmModule: undefined,
},
},
});

expect(wrapper.exists()).toBe(true);
expect(wrapper.find('[name="target"]').exists()).toBe(true);
expect(wrapper.find('[name="arg"]').exists()).toBe(true);
expect(wrapper.find('[name="wasm"]').exists()).toBe(true);
});

it('when target type changes the modelValue picks up the change', async () => {
const wrapper = mount(AdvancedUpdateMode, {
props: {
modelValue: {
wasmInitArg: undefined,
target: undefined,
wasmModule: undefined,
},
},
}) as unknown as VueWrapper<
InstanceType<typeof AdvancedUpdateMode> & { upgradeTarget: ChangeCanisterTargetType }
>;

// picks up the change to upgrade station
wrapper.vm.upgradeTarget = ChangeCanisterTargetType.UpgradeStation;
await wrapper.vm.$nextTick();
const modelValue = wrapper.emitted('update:modelValue')?.[0]?.[0] as ChangeCanisterFormValue;
expect(modelValue.target).toEqual({ UpgradeStation: null });

// picks up the change to upgrade upgrader
wrapper.vm.upgradeTarget = ChangeCanisterTargetType.UpgradeUpgrader;
await wrapper.vm.$nextTick();
const modelValue2 = wrapper.emitted('update:modelValue')?.[1]?.[0] as ChangeCanisterFormValue;
expect(modelValue2.target).toEqual({ UpgradeUpgrader: null });
});
});
131 changes: 131 additions & 0 deletions apps/wallet/src/components/change-canister/AdvancedUpdateMode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<template>
<VAlert type="warning" density="compact" variant="tonal" class="mb-4">
{{ $t('app.advanced_software_update_warning') }}
</VAlert>

<VSelect
v-model="upgradeTarget"
name="target"
:items="upgradeTargetItems"
:label="$t('app.canister_upgrade_target')"
:prepend-icon="mdiTarget"
variant="filled"
density="comfortable"
/>

<VFileInput
v-model="wasmModuleFile"
name="wasm"
:label="$t('app.canister_wasm_module')"
:rules="[requiredRule]"
:prepend-icon="mdiCube"
variant="filled"
density="comfortable"
/>

<VTextarea
v-model="wasmInitArg"
name="arg"
:label="$t(`app.canister_upgrade_args_input`)"
:prepend-icon="mdiCodeArray"
:hint="$t(`app.canister_upgrade_args_input_hint`)"
variant="filled"
density="comfortable"
/>
</template>

<script lang="ts" setup>
import { mdiCodeArray, mdiCube, mdiTarget } from '@mdi/js';
import { computed, ref, watch } from 'vue';
import { VAlert, VFileInput, VSelect, VTextarea } from 'vuetify/components';
import { ChangeCanisterFormValue } from '~/components/change-canister/change-canister.types';
import {
useDefaultUpgradeFormValue,
useUpgradeTargets,
} from '~/composables/change-canister.composable';
import logger from '~/core/logger.core';
import { ChangeCanisterTargetType } from '~/types/station.types';
import { readFileAsArrayBuffer } from '~/utils/file.utils';
import { requiredRule } from '~/utils/form.utils';

const props = defineProps<{
modelValue: ChangeCanisterFormValue;
}>();

const emit = defineEmits<{
(event: 'update:modelValue', payload: ChangeCanisterFormValue): void;
}>();

const modelValue = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});

const wasmModuleFile = ref<File[]>([]);
const upgradeTargets = useUpgradeTargets();
const upgradeTarget = ref<ChangeCanisterTargetType>(ChangeCanisterTargetType.UpgradeStation);
const upgradeTargetItems = computed(() => Object.values(upgradeTargets.value));
const wasmInitArg = ref<string>(props.modelValue.wasmInitArg ?? '');

const updateComputedCanisterModule = async () => {
if (!wasmModuleFile.value || wasmModuleFile.value.length === 0) {
modelValue.value = {
...modelValue.value,
wasmModule: undefined,
};

return;
}

try {
const wasmModule = await readFileAsArrayBuffer(wasmModuleFile.value[0]);
modelValue.value = {
...modelValue.value,
wasmModule,
};
} catch (error) {
logger.error('Failed to read wasm module file', error);
}
};

watch(
keplervital marked this conversation as resolved.
Show resolved Hide resolved
() => wasmModuleFile.value,
() => updateComputedCanisterModule(),
{ deep: true },
);

watch(
() => upgradeTarget.value,
() => {
switch (upgradeTarget.value) {
case ChangeCanisterTargetType.UpgradeStation:
modelValue.value = {
...modelValue.value,
target: { UpgradeStation: null },
};
break;
case ChangeCanisterTargetType.UpgradeUpgrader:
modelValue.value = {
...modelValue.value,
target: { UpgradeUpgrader: null },
};
break;
default:
wasmModuleFile.value = [];
modelValue.value = useDefaultUpgradeFormValue();
break;
}
},
{ immediate: true },
);

watch(
() => wasmInitArg.value,
() => {
modelValue.value = {
...modelValue.value,
wasmInitArg: wasmInitArg.value?.length ? wasmInitArg.value : undefined,
};
},
);
</script>
Original file line number Diff line number Diff line change
@@ -1,81 +1,164 @@
<template>
<ActionBtn
v-model="upgradeModel"
:text="$t('app.submit_upgrade')"
:title="$t('app.submit_upgrade')"
:text="$t('app.software_update')"
:title="$t('app.software_update')"
size="default"
variant="outlined"
density="comfortable"
:submit="form => submitUpgrade(form.modelValue as ChangeCanisterFormProps['modelValue'])"
data-test-id="submit-upgrade-btn"
@opened="emit('editing', true)"
@closed="emit('editing', false)"
@closed="onClosed"
@failed="useOnFailedOperation"
@submitted="useOnSuccessfulOperation"
>
<template #default="{ model: elem, submit }">
<template #default="{ model: elem }">
<ChangeCanisterForm
v-show="screen === ChangeCanisterScreen.Form"
:mode="formMode"
:model-value="elem.value.modelValue as ChangeCanisterFormProps['modelValue']"
@update:model-value="elem.value.modelValue = $event"
@valid="elem.value.valid = $event"
@submit="submit"
@loading="formLoading = $event"
@submit="goToConfirmation(elem.value.modelValue)"
/>

<ChangeCanisterConfirmationScreen
v-if="screen === ChangeCanisterScreen.Confirm"
:wasm-module-checksum="wasmChecksum"
:comment="elem.value.modelValue.comment"
@update:comment="
elem.value.modelValue = {
...elem.value.modelValue,
comment: $event,
}
"
/>
</template>
<template #actions="{ submit, loading: saving, model: elem }">
<VSpacer />
<VBtn
:loading="saving"
:disabled="!elem.value.valid"
color="primary"
variant="flat"
@click="submit"
v-if="screen === ChangeCanisterScreen.Form"
:disabled="saving"
:append-icon="
formMode === ChangeCanisterFormMode.Advanced ? mdiCloudDownload : mdiWrenchCog
"
variant="text"
@click="toggleFormMode"
>
{{ $t('terms.submit') }}
{{
formMode === ChangeCanisterFormMode.Advanced
? $t('terms.automated')
: $t('terms.advanced')
}}
</VBtn>
<VSpacer />
<div class="d-flex align-md-center justify-end flex-column-reverse flex-md-row ga-2">
<VBtn
v-if="
screen === ChangeCanisterScreen.Form && formMode === ChangeCanisterFormMode.Registry
"
:disabled="station.versionManagement.loading || formLoading"
color="primary"
variant="text"
:append-icon="mdiRefresh"
size="small"
@click="station.checkVersionUpdates"
>
{{ $t('app.check_updates_btn') }}
</VBtn>
<VBtn
v-if="screen === ChangeCanisterScreen.Form"
:loading="saving"
:disabled="!elem.value.valid"
color="primary"
variant="flat"
@click="goToConfirmation(elem.value.modelValue)"
>
{{ $t('terms.continue') }}
</VBtn>
<VBtn
v-else-if="screen === ChangeCanisterScreen.Confirm"
:loading="saving"
:disabled="saving"
color="primary"
variant="flat"
@click="submit"
>
{{ $t('terms.submit') }}
</VBtn>
</div>
</template>
</ActionBtn>
</template>

<script lang="ts" setup>
import { mdiCloudDownload, mdiRefresh, mdiWrenchCog } from '@mdi/js';
import { ref } from 'vue';
import { VBtn } from 'vuetify/components';
import ActionBtn from '~/components/buttons/ActionBtn.vue';
import ChangeCanisterForm, {
ChangeCanisterFormProps,
} from '~/components/change-canister/ChangeCanisterForm.vue';
import { useDefaultUpgradeModel } from '~/composables/change-canister.composable';
import {
useOnFailedOperation,
useOnSuccessfulOperation,
} from '~/composables/notifications.composable';
import { Request } from '~/generated/station/station.did';
import { useStationStore } from '~/stores/station.store';
import { hexStringToArrayBuffer } from '~/utils/crypto.utils';
import { readFileAsArrayBuffer } from '~/utils/file.utils';
import { arrayBufferToHashHex, hexStringToArrayBuffer } from '~/utils/crypto.utils';
import { assertAndReturn } from '~/utils/helper.utils';
import { ChangeCanisterFormMode, ChangeCanisterScreen } from './change-canister.types';
import ChangeCanisterConfirmationScreen from './ChangeCanisterConfirmationScreen.vue';

const station = useStationStore();
const upgradeModel = ref<ChangeCanisterFormProps>(useDefaultUpgradeModel());
const screen = ref<ChangeCanisterScreen>(ChangeCanisterScreen.Form);
const formMode = ref<ChangeCanisterFormMode>(ChangeCanisterFormMode.Registry);
const toggleFormMode = () => {
upgradeModel.value = useDefaultUpgradeModel();
formMode.value =
formMode.value === ChangeCanisterFormMode.Advanced
? ChangeCanisterFormMode.Registry
: ChangeCanisterFormMode.Advanced;
};
const wasmChecksum = ref<string>('');
const formLoading = ref(false);
const goToConfirmation = async (model: ChangeCanisterFormProps['modelValue']): Promise<void> => {
const wasmModule = assertAndReturn(model.wasmModule, 'model.wasmModule is required');
wasmChecksum.value = await arrayBufferToHashHex(wasmModule);

const upgradeModel = ref<ChangeCanisterFormProps>({
modelValue: {
target: null,
wasmModule: undefined,
arg: null,
},
valid: false,
});
screen.value = ChangeCanisterScreen.Confirm;
};

const submitUpgrade = async (model: ChangeCanisterFormProps['modelValue']): Promise<Request> => {
const wasmModule = assertAndReturn(model.wasmModule?.[0], 'model.wasmModule is required');
const fileBuffer = await readFileAsArrayBuffer(wasmModule);
const fileBuffer = assertAndReturn(model.wasmModule, 'model.wasmModule is required');

return station.service.changeCanister({
arg:
model.arg && model.arg.length > 0 ? [new Uint8Array(hexStringToArrayBuffer(model.arg))] : [],
module: new Uint8Array(fileBuffer),
target: assertAndReturn(model.target),
});
return station.service.changeCanister(
{
arg:
model.wasmInitArg && model.wasmInitArg.length > 0
? [new Uint8Array(hexStringToArrayBuffer(model.wasmInitArg))]
: [],
module: new Uint8Array(fileBuffer),
target: assertAndReturn(model.target, 'model.target is required'),
},
{
comment: model.comment,
},
);
};

const emit = defineEmits<{
(event: 'editing', payload: boolean): void;
}>();

const onClosed = () => {
formMode.value = ChangeCanisterFormMode.Registry;
screen.value = ChangeCanisterScreen.Form;
upgradeModel.value = useDefaultUpgradeModel();

emit('editing', false);
};
</script>
Loading
Loading