Skip to content
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
98 changes: 64 additions & 34 deletions client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,30 @@ export default {
this.files = files;
this.file = files.file;
},
// Every start button below asks the board to begin something. Four of them
// set the state optimistically first, so the button can turn into Cancel -
// and nothing put that state back when the request failed. The UI then sat
// on a progress bar, rate and countdown included, rendered from the last
// poll of a previous transfer, for one that had never started (#166). The
// two that do not set the state failed silently instead, which swallowed
// the 409 that start_installation and start_magic answer with when the eMMC
// has stopped responding - the most useful sentence the board can say
// (#137).
transferFailed(what, err) {
this.state = "IDLE";
// A start that failed owns nothing, so the unload beacon must not fire a
// cancel at whatever is running by the time this tab closes.
this.ownsUpload = false;
// The board's own words when it answered, the transport error when it
// never did.
const detail =
(err && err.response && err.response.data) || (err && err.message) || "";
this.$waveui.notify(
`Could not start the ${what}.` + (detail ? ` ${detail}` : ""),
"error",
0
);
},
async apiCall(call) {
var self = this;
// Whatever ends the upload also ends this tab's ownership of it, so a
Expand Down Expand Up @@ -512,17 +536,18 @@ export default {
this.resetProgressBars();
this.state = "UPLOADING_MAGIC";
this.ownsUpload = true;
await axios
.put(`/api/upload_magic_start`, {
try {
const response = await axios.put(`/api/upload_magic_start`, {
filename: self.file.name,
size: self.file.size,
start_time: Date.now(),
})
.then(function (response) {
self.status = response.data["success"];
self.magicUploadLocalFile();
self.checkProgress();
});
self.status = response.data["success"];
self.magicUploadLocalFile();
self.checkProgress();
} catch (err) {
this.transferFailed("magic upload", err);
}
} else {
this.apiCall("upload_cancel");
}
Expand Down Expand Up @@ -638,17 +663,18 @@ export default {
this.resetProgressBars();
this.state = "UPLOADING";
this.ownsUpload = true;
await axios
.put(`/api/upload_start`, {
try {
const response = await axios.put(`/api/upload_start`, {
filename: self.file.name,
size: self.file.size,
start_time: Date.now(),
})
.then(function (response) {
self.status = response.data["success"];
self.uploadLocalFile();
self.checkProgress();
});
self.status = response.data["success"];
self.uploadLocalFile();
self.checkProgress();
} catch (err) {
this.transferFailed("upload", err);
}
} else {
this.apiCall("upload_cancel");
}
Expand Down Expand Up @@ -766,16 +792,17 @@ export default {
let self = this;
if (this.state == "IDLE") {
this.state = "MAGIC";
await axios
.put(`/api/start_magic`, {
try {
await axios.put(`/api/start_magic`, {
filename: this.selectedGithubImage["name"],
size: this.selectedGithubImage["size"],
url: this.selectedGithubImage["url"],
start_time: Date.now(),
})
.then(() => {
self.checkProgress();
});
self.checkProgress();
} catch (err) {
this.transferFailed("magic flash", err);
}
} else {
axios.put(`/api/cancel_magic`);
}
Expand Down Expand Up @@ -885,16 +912,17 @@ export default {
let self = this;
if (this.state == "IDLE") {
this.state = "DOWNLOADING";
await axios
.put(`/api/start_download`, {
try {
await axios.put(`/api/start_download`, {
filename: this.selectedGithubImage["name"],
size: this.selectedGithubImage["size"],
url: this.selectedGithubImage["url"],
start_time: Date.now(),
})
.then(() => {
self.checkProgress();
});
self.checkProgress();
} catch (err) {
this.transferFailed("download", err);
}
} else {
axios.put(`/api/cancel_download`);
}
Expand Down Expand Up @@ -952,28 +980,30 @@ export default {
},
async installSelected() {
let self = this;
await axios
.put(`/api/start_installation`, {
try {
await axios.put(`/api/start_installation`, {
filename: this.selectedLocalImage,
start_time: Date.now(),
})
.then(() => {
self.checkProgress();
});
self.checkProgress();
} catch (err) {
this.transferFailed("installation", err);
}
},
async backupSelected() {
this.setProgress({ progress: 0 });
this.resetProgressBars();
this.$refs.installprogressbar.update();
let self = this;
await axios
.put(`/api/start_backup`, {
try {
await axios.put(`/api/start_backup`, {
filename: this.backupFile,
start_time: Date.now(),
})
.then(() => {
self.checkProgress();
});
self.checkProgress();
} catch (err) {
this.transferFailed("backup", err);
}
},
rebootBoard() {
this.showOverlay = true;
Expand Down
108 changes: 108 additions & 0 deletions client/tests/unit/TransferStart.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
import App from '@/App.vue';

vi.mock('axios');

// Every one of these buttons asks the board to start something. If that request
// does not land, the ones that set the state optimistically used to leave the UI
// on a progress bar for a transfer that never started (#166), and the ones that
// do not set it said nothing at all - including when the board had answered 409
// with a reason worth reading.
function stand(over = {}) {
return {
state: 'IDLE',
ownsUpload: false,
status: null,
backupFile: 'backup',
file: { name: 'image.img.xz', size: 1024 },
selectedGithubImage: { name: 'image.img.xz', size: 1024, url: 'http://example/image.img.xz' },
selectedLocalImage: 'image.img.xz',
checkProgress: vi.fn(),
resetProgressBars: vi.fn(),
setProgress: vi.fn(),
uploadLocalFile: vi.fn(),
magicUploadLocalFile: vi.fn(),
$refs: { installprogressbar: { update: vi.fn() } },
$waveui: { notify: vi.fn() },
// The real one, not a stub: the failure handling is what is under test
// here, and on a live component every method sits on the same instance.
transferFailed: App.methods.transferFailed,
...over,
};
}

const started = [
['downloadSelected', 'DOWNLOADING'],
['uploadSelected', 'UPLOADING'],
['startMagic', 'MAGIC'],
['startMagicUpload', 'UPLOADING_MAGIC'],
];

describe('a transfer that cannot be started does not leave the UI pretending (#166)', () => {
beforeEach(() => vi.clearAllMocks());

for (const [method, busyState] of started) {
it(`${method}: puts the state back to IDLE when the request fails`, async () => {
axios.put.mockRejectedValue(new Error('Network Error'));
const self = stand();

await App.methods[method].call(self);

expect(self.state).toBe('IDLE');
expect(self.checkProgress).not.toHaveBeenCalled();
expect(self.$waveui.notify).toHaveBeenCalled();
});

it(`${method}: leaves the state alone when the request succeeds`, async () => {
axios.put.mockResolvedValue({ data: { success: true } });
const self = stand();

await App.methods[method].call(self);

expect(self.state).toBe(busyState);
expect(self.$waveui.notify).not.toHaveBeenCalled();
});
}

// The uploads also claim ownership of the transfer for this tab, which drives
// the cancel-on-unload beacon. A start that failed owns nothing.
it('a failed upload start does not leave this tab owning the upload', async () => {
axios.put.mockRejectedValue(new Error('Network Error'));
const self = stand();

await App.methods.uploadSelected.call(self);

expect(self.ownsUpload).toBe(false);
});
});

// start_installation and start_magic answer 409 with a sentence explaining that
// the eMMC stopped responding and the board needs a power cycle (#137). It is
// the most useful thing the server can say, and it was being dropped.
describe('the board\'s refusal reaches the user', () => {
beforeEach(() => vi.clearAllMocks());

it('installSelected surfaces the message from a refused install', async () => {
axios.put.mockRejectedValue({
response: { status: 409, data: 'The eMMC stopped responding. Power cycle the board and try again.' },
});
const self = stand();

await App.methods.installSelected.call(self);

expect(self.checkProgress).not.toHaveBeenCalled();
const said = self.$waveui.notify.mock.calls.map(([msg]) => msg).join(' ');
expect(said).toContain('eMMC stopped responding');
});

it('backupSelected reports a backup that could not be started', async () => {
axios.put.mockRejectedValue(new Error('Network Error'));
const self = stand();

await App.methods.backupSelected.call(self);

expect(self.checkProgress).not.toHaveBeenCalled();
expect(self.$waveui.notify).toHaveBeenCalled();
});
});
Loading