Skip to content
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Principal } from '@dfinity/principal';
import { describe, expect, it } from 'vitest';
import { VCheckbox } from 'vuetify/components';
import CanisterWasmMemoryPersistenceSelect from '~/components/inputs/CanisterWasmMemoryPersistenceSelect.vue';
import { mount } from '~/test.utils';
import CanisterInstallForm from './CanisterInstallForm.vue';

Expand Down Expand Up @@ -27,4 +29,93 @@ describe('CanisterInstallForm', () => {

expect(canisterIdInput.exists()).toBe(true);
});

it('hides the upgrade options unless the mode is upgrade', () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: { mode: { install: null } },
},
});

expect(form.findComponent(CanisterWasmMemoryPersistenceSelect).exists()).toBe(false);
});

it('shows the upgrade options when the mode is upgrade', () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: { mode: { upgrade: [] } },
},
});

expect(form.findComponent(CanisterWasmMemoryPersistenceSelect).exists()).toBe(true);
});

it('folds the selected wasm_memory_persistence into the upgrade mode', async () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: { mode: { upgrade: [] } },
},
});

form.findComponent(CanisterWasmMemoryPersistenceSelect).vm.$emit('update:modelValue', {
keep: null,
});
await form.vm.$nextTick();

const updates = form.emitted('update:modelValue');
expect(updates).toBeTruthy();
expect(updates?.at(-1)?.[0]).toEqual({
mode: { upgrade: [{ wasm_memory_persistence: [{ keep: null }], skip_pre_upgrade: [] }] },
});
});

it('collapses back to a plain upgrade when the options are cleared', async () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: {
mode: { upgrade: [{ wasm_memory_persistence: [{ keep: null }], skip_pre_upgrade: [] }] },
},
},
});

form
.findComponent(CanisterWasmMemoryPersistenceSelect)
.vm.$emit('update:modelValue', undefined);
await form.vm.$nextTick();

const updates = form.emitted('update:modelValue');
expect(updates?.at(-1)?.[0]).toEqual({ mode: { upgrade: [] } });
});

it('folds the skip_pre_upgrade toggle into the upgrade mode', async () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: { mode: { upgrade: [] } },
},
});

form.findComponent(VCheckbox).vm.$emit('update:modelValue', true);
await form.vm.$nextTick();

const updates = form.emitted('update:modelValue');
expect(updates?.at(-1)?.[0]).toEqual({
mode: { upgrade: [{ wasm_memory_persistence: [], skip_pre_upgrade: [true] }] },
});
});

it('collapses back to a plain upgrade when skip_pre_upgrade is disabled', async () => {
const form = mount(CanisterInstallForm, {
props: {
modelValue: {
mode: { upgrade: [{ wasm_memory_persistence: [], skip_pre_upgrade: [true] }] },
},
},
});

form.findComponent(VCheckbox).vm.$emit('update:modelValue', false);
await form.vm.$nextTick();

const updates = form.emitted('update:modelValue');
expect(updates?.at(-1)?.[0]).toEqual({ mode: { upgrade: [] } });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@
<VCol cols="12" class="pb-0">
<CanisterInstallModeSelect v-model="model.mode" :readonly="props.readonly" required />
</VCol>
<template v-if="isUpgradeMode">
<VCol cols="12" class="pb-0">
<CanisterWasmMemoryPersistenceSelect
v-model="wasmMemoryPersistence"
:readonly="props.readonly"
:hint="$t('external_canisters.wasm_memory_persistence.hint')"
/>
</VCol>
<VCol cols="12" class="pb-0">
<VCheckbox
v-model="skipPreUpgrade"
:readonly="props.readonly"
:label="$t('external_canisters.skip_pre_upgrade.label')"
:hint="$t('external_canisters.skip_pre_upgrade.hint')"
persistent-hint
density="comfortable"
:prepend-icon="mdiDebugStepOver"
/>
</VCol>
</template>
<VCol cols="12" class="pb-0">
<CanisterWasmModuleField
v-model="model.wasmModule"
Expand All @@ -38,14 +58,21 @@
</VForm>
</template>
<script lang="ts" setup>
import { mdiDebugStepOver } from '@mdi/js';
import { computed, ref, watch } from 'vue';
import { VCol, VContainer, VForm, VRow } from 'vuetify/components';
import { VCheckbox, VCol, VContainer, VForm, VRow } from 'vuetify/components';
import CanisterArgumentField from '~/components/inputs/CanisterArgumentField.vue';
import CanisterInstallModeSelect from '~/components/inputs/CanisterInstallModeSelect.vue';
import CanisterWasmMemoryPersistenceSelect from '~/components/inputs/CanisterWasmMemoryPersistenceSelect.vue';
import CanisterWasmModuleField from '~/components/inputs/CanisterWasmModuleField.vue';
import { VFormValidation } from '~/types/helper.types';
import CanisterIdField from '../inputs/CanisterIdField.vue';
import { CanisterIcSettingsModel, CanisterInstallModel } from './external-canisters.types';
import {
CanisterIcSettingsModel,
CanisterInstallModel,
CanisterUpgradeOptions,
WasmMemoryPersistence,
} from './external-canisters.types';

const props = withDefaults(
defineProps<{
Expand Down Expand Up @@ -83,6 +110,46 @@ const model = computed({
set: value => emit('update:modelValue', value),
});

const isUpgradeMode = computed(() => !!model.value.mode && 'upgrade' in model.value.mode);

const upgradeOptions = computed<CanisterUpgradeOptions | undefined>(() => {
const mode = model.value.mode;
if (mode && 'upgrade' in mode && mode.upgrade.length > 0) {
return mode.upgrade[0];
}

return undefined;
});

// Merges a partial patch into the upgrade-options record, collapsing back to an
// empty record (`{ upgrade: [] }`) when neither option is set so a plain
// upgrade request is emitted.
const setUpgradeOptions = (patch: Partial<CanisterUpgradeOptions>): void => {
const mode = model.value.mode;
if (!mode || !('upgrade' in mode)) {
return;
}

const current: CanisterUpgradeOptions = upgradeOptions.value ?? {
wasm_memory_persistence: [],
skip_pre_upgrade: [],
};
const next: CanisterUpgradeOptions = { ...current, ...patch };
const isEmpty = next.wasm_memory_persistence.length === 0 && next.skip_pre_upgrade.length === 0;

model.value = { ...model.value, mode: { upgrade: isEmpty ? [] : [next] } };
};

const wasmMemoryPersistence = computed<WasmMemoryPersistence | undefined>({
get: () => upgradeOptions.value?.wasm_memory_persistence?.[0],
set: value => setUpgradeOptions({ wasm_memory_persistence: value !== undefined ? [value] : [] }),
});

const skipPreUpgrade = computed<boolean>({
get: () => upgradeOptions.value?.skip_pre_upgrade?.[0] ?? false,
set: value => setUpgradeOptions({ skip_pre_upgrade: value ? [true] : [] }),
});

const triggerSubmit = computed({
get: () => props.triggerSubmit,
set: value => emit('update:triggerSubmit', value),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ export interface CanisterInstallModel {
mode?: CanisterInstallMode;
}

// The optional upgrade-options record carried by the `upgrade` variant of
// `CanisterInstallMode`. Derived from the generated type so it stays in sync.
export type CanisterUpgradeOptions = NonNullable<
Extract<CanisterInstallMode, { upgrade: unknown }>['upgrade'][number]
>;

export type WasmMemoryPersistence = NonNullable<
CanisterUpgradeOptions['wasm_memory_persistence'][number]
>;

export interface CanisterMethodCallConfigurationModel {
canisterId: Principal;
alreadyConfiguredMethods: CanisterConfiguredMethodCall[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { VSelect } from 'vuetify/components';
import { mount } from '~/test.utils';
import CanisterWasmMemoryPersistenceSelect from './CanisterWasmMemoryPersistenceSelect.vue';

describe('CanisterWasmMemoryPersistenceSelect', () => {
it('renders a select', () => {
const wrapper = mount(CanisterWasmMemoryPersistenceSelect, {
props: { modelValue: undefined },
});

expect(wrapper.findComponent(VSelect).exists()).toBe(true);
});

it('maps the candid value to the select option', () => {
expect(
mount(CanisterWasmMemoryPersistenceSelect, { props: { modelValue: undefined } })
.findComponent(VSelect)
.props('modelValue'),
).toBe('default');

expect(
mount(CanisterWasmMemoryPersistenceSelect, { props: { modelValue: { keep: null } } })
.findComponent(VSelect)
.props('modelValue'),
).toBe('keep');

expect(
mount(CanisterWasmMemoryPersistenceSelect, { props: { modelValue: { replace: null } } })
.findComponent(VSelect)
.props('modelValue'),
).toBe('replace');
});

it('emits the candid union value when an option is selected', async () => {
const wrapper = mount(CanisterWasmMemoryPersistenceSelect, {
props: { modelValue: undefined },
});
const select = wrapper.findComponent(VSelect);

select.vm.$emit('update:modelValue', 'keep');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ keep: null });

select.vm.$emit('update:modelValue', 'replace');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ replace: null });
});

it('emits undefined when the default option is selected', async () => {
const wrapper = mount(CanisterWasmMemoryPersistenceSelect, {
props: { modelValue: { keep: null } },
});

wrapper.findComponent(VSelect).vm.$emit('update:modelValue', 'default');
await wrapper.vm.$nextTick();

expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<VSelect
v-model="selected"
:label="label"
:variant="props.variant"
:density="props.density"
:readonly="props.readonly"
:items="items"
:hint="props.hint"
:persistent-hint="props.hint !== undefined"
:prepend-icon="mdiMemory"
/>
</template>
<script setup lang="ts">
import { mdiMemory } from '@mdi/js';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { VSelect } from 'vuetify/components';
import { WasmMemoryPersistence } from '~/components/external-canisters/external-canisters.types';

type WasmMemoryPersistenceOption = 'default' | 'keep' | 'replace';

const props = withDefaults(
defineProps<{
modelValue?: WasmMemoryPersistence;
readonly?: boolean;
label?: string;
hint?: string;
density?: 'comfortable' | 'compact' | 'default';
variant?: 'filled' | 'outlined' | 'plain' | 'solo' | 'underlined';
}>(),
{
modelValue: undefined,
readonly: false,
label: undefined,
hint: undefined,
density: 'comfortable',
variant: 'filled',
},
);

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

const i18n = useI18n();
const label = computed(
() => props.label ?? i18n.t('external_canisters.wasm_memory_persistence.label'),
);

const selected = computed<WasmMemoryPersistenceOption>({
get: () => {
if (!props.modelValue) {
return 'default';
}

return 'keep' in props.modelValue ? 'keep' : 'replace';
},
set: value => {
switch (value) {
case 'keep':
emit('update:modelValue', { keep: null });
break;
case 'replace':
emit('update:modelValue', { replace: null });
break;
case 'default':
// `default` means "let the IC decide" and maps to an absent
// wasm_memory_persistence option in the request.
emit('update:modelValue', undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like sweeping a problem under the rug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b88cd2e — the catch-all default: arm is gone; 'default' is now an explicit case with a comment explaining it means "let the IC decide" and maps to an absent wasm_memory_persistence option in the request. The select's items are constrained to the three options, so every case is now spelled out.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the IC decide automatically?? If so, then, it seems that this feature is not super necessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — the IC never infers keep. What it does is fail closed: upgrading a canister whose module declares EOP without keep is rejected outright (so no silent memory loss, but also no upgrade). It's dfx that infers client-side from the Wasm metadata and fills the flag in for you. So the option is necessary — without it, EOP canisters simply cannot be upgraded through Orbit at all. The new integration test in 595ddfd demonstrates both halves (rejected without keep, succeeds with it).


Generated by Claude Code

break;
}
},
Comment thread
aterga marked this conversation as resolved.
});

const items = computed<{ title: string; value: WasmMemoryPersistenceOption }[]>(() => [
{ title: i18n.t('external_canisters.wasm_memory_persistence.default'), value: 'default' },
{ title: i18n.t('external_canisters.wasm_memory_persistence.keep'), value: 'keep' },
{ title: i18n.t('external_canisters.wasm_memory_persistence.replace'), value: 'replace' },
]);
</script>
11 changes: 11 additions & 0 deletions apps/wallet/src/locales/en.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,17 @@ export default {
upgrade: 'Upgrade',
install: 'Install',
},
wasm_memory_persistence: {
label: 'Wasm Memory Persistence',
hint: 'Controls the canister main memory on upgrade. Motoko canisters using Enhanced Orthogonal Persistence require "Keep".',
default: 'Default (replace)',
keep: 'Keep',
replace: 'Replace',
},
skip_pre_upgrade: {
label: 'Skip pre-upgrade hook',
hint: 'Skips the canister pre_upgrade hook during the upgrade. Useful for recovery when the hook traps.',
},
},
terms: {
license: 'License',
Expand Down
11 changes: 11 additions & 0 deletions apps/wallet/src/locales/fr.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,17 @@ export default {
upgrade: 'Mettre à jour',
install: 'Installer',
},
wasm_memory_persistence: {
label: 'Persistance de la mémoire Wasm',
hint: 'Contrôle la mémoire principale du canister lors de la mise à jour. Les canisters Motoko utilisant la persistance orthogonale améliorée nécessitent « Conserver ».',
default: 'Par défaut (remplacer)',
keep: 'Conserver',
replace: 'Remplacer',
},
skip_pre_upgrade: {
label: 'Ignorer le hook pre-upgrade',
hint: 'Ignore le hook pre_upgrade du canister lors de la mise à jour. Utile pour la récupération lorsque le hook échoue.',
},
},
terms: {
license: 'Licence',
Expand Down
Loading
Loading