1
0
Fork 0
mirror of https://github.com/betaflight/betaflight-configurator.git synced 2025-07-25 01:05:15 +03:00

Merge pull request #995 from ademuk/cli-tab-auto-complete

Capture tab key press in CLI and forward command to fc with appended …
This commit is contained in:
Michael Keller 2018-06-03 22:26:53 +12:00 committed by GitHub
commit f7078e445f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 3247 additions and 215 deletions

View file

@ -19,11 +19,15 @@ language: node_js
node_js: node_js:
- 8.11.2 - 8.11.2
addons:
chrome: stable
before_install: before_install:
- npm i -g npm@6.0.1 - npm i -g npm@6.0.1
- npm install gulp -g - npm install gulp -g
script: script:
- npm test
- gulp release - gulp release
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then gulp release --chromeos; fi - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then gulp release --chromeos; fi

View file

@ -58,6 +58,10 @@ Linux build is disabled currently because of unmet dependecies with some distros
2. Change to project folder and run `npm install`. 2. Change to project folder and run `npm install`.
3. Run `npm start`. 3. Run `npm start`.
### Running tests
`npm test`
### App build and release ### App build and release
The tasks are defined in `gulpfile.js` and can be run either via `gulp <task-name>` (if the command is in PATH or via `../node_modules/gulp/bin/gulp.js <task-name>`: The tasks are defined in `gulpfile.js` and can be run either via `gulp <task-name>` (if the command is in PATH or via `../node_modules/gulp/bin/gulp.js <task-name>`:

2887
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,8 @@
"start": "node node_modules/gulp/bin/gulp.js debug", "start": "node node_modules/gulp/bin/gulp.js debug",
"_postinstall": "node ./node_modules/platform-dependent-modules/cli.js", "_postinstall": "node ./node_modules/platform-dependent-modules/cli.js",
"postinstall": "npm run _postinstall", "postinstall": "npm run _postinstall",
"gulp": "gulp" "gulp": "gulp",
"test": "karma start test/karma.conf.js"
}, },
"window": { "window": {
"show": false, "show": false,
@ -40,6 +41,7 @@
"marked": "^0.3.12" "marked": "^0.3.12"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.1.2",
"command-exists": "^1.2.2", "command-exists": "^1.2.2",
"del": "^3.0.0", "del": "^3.0.0",
"follow-redirects": "^1.4.1", "follow-redirects": "^1.4.1",
@ -52,11 +54,20 @@
"gulp-zip": "^4.1.0", "gulp-zip": "^4.1.0",
"inflection": "1.12.0", "inflection": "1.12.0",
"jquery-ui-npm": "1.12.0", "jquery-ui-npm": "1.12.0",
"karma": "^2.0.0",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-mocha": "^1.3.0",
"karma-sinon": "^1.0.5",
"karma-sinon-chai": "^1.3.4",
"makensis": "^0.9.0", "makensis": "^0.9.0",
"mocha": "^5.2.0",
"nw-builder": "^3.4.1", "nw-builder": "^3.4.1",
"os": "^0.1.1", "os": "^0.1.1",
"platform-dependent-modules": "0.0.14", "platform-dependent-modules": "0.0.14",
"rpm-builder": "^0.7.0", "rpm-builder": "^0.7.0",
"sinon": "^5.0.10",
"sinon-chai": "^3.1.0",
"targz": "^1.0.1", "targz": "^1.0.1",
"temp": "^0.8.3" "temp": "^0.8.3"
}, },

View file

@ -1,14 +1,46 @@
'use strict'; 'use strict';
TABS.cli = { TABS.cli = {
'validateText': "",
'currentLine': "",
'sequenceElements': 0,
lineDelayMs: 15, lineDelayMs: 15,
profileSwitchDelayMs: 100, profileSwitchDelayMs: 100,
outputHistory: "" outputHistory: "",
cliBuffer: ""
}; };
function removePromptHash(promptText) {
return promptText.replace(/^# /, '');
}
function cliBufferCharsToDelete(command, buffer) {
var commonChars = 0;
for (var i = 0;i < buffer.length;i++) {
if (command[i] === buffer[i]) {
commonChars++;
} else {
break;
}
}
return buffer.length - commonChars;
}
function commandWithBackSpaces(command, buffer, noOfCharsToDelete) {
const backspace = String.fromCharCode(127);
return backspace.repeat(noOfCharsToDelete) + command.substring(buffer.length - noOfCharsToDelete, command.length);
}
function getCliCommand(command, cliBuffer) {
const buffer = removePromptHash(cliBuffer);
const bufferRegex = new RegExp('^' + buffer, 'g');
if (command.match(bufferRegex)) {
return command.replace(bufferRegex, '');
}
const noOfCharsToDelete = cliBufferCharsToDelete(command, buffer);
return commandWithBackSpaces(command, buffer, noOfCharsToDelete);
}
TABS.cli.initialize = function (callback) { TABS.cli.initialize = function (callback) {
var self = this; var self = this;
@ -17,6 +49,7 @@ TABS.cli.initialize = function (callback) {
} }
self.outputHistory = ""; self.outputHistory = "";
self.cliBuffer = "";
$('#content').load("./tabs/cli.html", function () { $('#content').load("./tabs/cli.html", function () {
// translate to user-selected language // translate to user-selected language
@ -58,8 +91,8 @@ TABS.cli.initialize = function (callback) {
writer.write(new Blob([self.outputHistory], {type: 'text/plain'})); writer.write(new Blob([self.outputHistory], {type: 'text/plain'}));
} else { } else {
console.log('write complete'); console.log('write complete');
}; }
} };
writer.truncate(0); writer.truncate(0);
}, function (){ }, function (){
@ -68,22 +101,43 @@ TABS.cli.initialize = function (callback) {
}); });
}); });
// Tab key detection must be on keydown,
// `keypress`/`keyup` happens too late, as `textarea` will have already lost focus.
textarea.keydown(function (event) {
const tabKeyCode = 9;
if (event.which == tabKeyCode) {
// prevent default tabbing behaviour
event.preventDefault();
const outString = textarea.val();
const lastCommand = outString.split("\n").pop();
const command = getCliCommand(lastCommand, self.cliBuffer);
if (command) {
self.sendAutoComplete(command);
textarea.val('');
}
}
});
textarea.keypress(function (event) { textarea.keypress(function (event) {
if (event.which == 13) { // enter const enterKeyCode = 13;
if (event.which == enterKeyCode) {
event.preventDefault(); // prevent the adding of new line event.preventDefault(); // prevent the adding of new line
var out_string = textarea.val(); var out_string = textarea.val();
self.history.add(out_string.trim()); self.history.add(out_string.trim());
var outputArray = out_string.split("\n"); var outputArray = out_string.split("\n");
Promise.reduce(outputArray, function(delay, line) { Promise.reduce(outputArray, function(delay, line, index) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
GUI.timeout_add('CLI_send_slowly', function () { GUI.timeout_add('CLI_send_slowly', function () {
var processingDelay = self.lineDelayMs; var processingDelay = self.lineDelayMs;
if (line.toLowerCase().startsWith('profile')) { if (line.toLowerCase().startsWith('profile')) {
processingDelay = self.profileSwitchDelayMs; processingDelay = self.profileSwitchDelayMs;
} }
const isLastCommand = outputArray.length === index + 1;
if (isLastCommand && self.cliBuffer) {
line = getCliCommand(line, self.cliBuffer);
}
self.sendLine(line, function () { self.sendLine(line, function () {
resolve(processingDelay); resolve(processingDelay);
}); });
@ -134,15 +188,34 @@ TABS.cli.history.add = function (str) {
this.history.push(str); this.history.push(str);
this.index = this.history.length; this.index = this.history.length;
}; };
TABS.cli.history.prev = function () { TABS.cli.history.prev = function () {
if (this.index > 0) this.index -= 1; if (this.index > 0) this.index -= 1;
return this.history[this.index]; return this.history[this.index];
}; };
TABS.cli.history.next = function () { TABS.cli.history.next = function () {
if (this.index < this.history.length) this.index += 1; if (this.index < this.history.length) this.index += 1;
return this.history[this.index - 1]; return this.history[this.index - 1];
}; };
const backspaceCode = 8;
const lineFeedCode = 10;
const carriageReturnCode = 13;
function writeToOutput(text) {
$('.tab-cli .window .wrapper').append(text);
$('.tab-cli .window').scrollTop($('.tab-cli .window .wrapper').height());
}
function writeLineToOutput(text) {
writeToOutput(text + "<br>");
}
function setPrompt(text) {
$('.tab-cli textarea').val(text);
}
TABS.cli.read = function (readInfo) { TABS.cli.read = function (readInfo) {
/* Some info about handling line feeds and carriage return /* Some info about handling line feeds and carriage return
@ -152,123 +225,122 @@ TABS.cli.read = function (readInfo) {
MAC only understands CR MAC only understands CR
Linux and Unix only understand LF Linux and Unix only understand LF
Windows understands (both) CRLF Windows understands (both) CRLF
Chrome OS currenty unknown Chrome OS currently unknown
*/ */
var data = new Uint8Array(readInfo.data), var data = new Uint8Array(readInfo.data),
text = ""; validateText = "",
sequenceCharsToSkip = 0;
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
if (CONFIGURATOR.cliValid) { const currentChar = String.fromCharCode(data[i]);
if (data[i] == 27 || this.sequenceElements > 0) { // ESC + other
this.sequenceElements++;
// delete previous space if (!CONFIGURATOR.cliValid) {
if (this.sequenceElements == 1) {
text = text.substring(0, text.length -1);
}
// Reset
if (this.sequenceElements >= 5) {
this.sequenceElements = 0;
}
}
if (this.sequenceElements == 0) {
switch (data[i]) {
case 10: // line feed
if (GUI.operating_system != "MacOS") {
text += "<br />";
}
this.currentLine = "";
break;
case 13: // carriage return
if (GUI.operating_system == "MacOS") {
text += "<br />";
}
this.currentLine = "";
break;
case 60:
text += '&lt';
break;
case 62:
text += '&gt';
break;
default:
text += String.fromCharCode(data[i]);
this.currentLine += String.fromCharCode(data[i]);
}
this.outputHistory += String.fromCharCode(data[i])
}
if (this.currentLine == 'Rebooting') {
CONFIGURATOR.cliActive = false;
CONFIGURATOR.cliValid = false;
GUI.log(i18n.getMessage('cliReboot'));
GUI.log(i18n.getMessage('deviceRebooting'));
if (BOARD.find_board_definition(CONFIG.boardIdentifier).vcp) { // VCP-based flight controls may crash old drivers, we catch and reconnect
$('a.connect').click();
GUI.timeout_add('start_connection',function start_connection() {
$('a.connect').click();
},2500);
} else {
GUI.timeout_add('waiting_for_bootup', function waiting_for_bootup() {
MSP.send_message(MSPCodes.MSP_STATUS, false, false, function() {
GUI.log(i18n.getMessage('deviceReady'));
if (!GUI.tab_switch_in_progress) {
$('#tabs ul.mode-connected .tab_setup a').click();
}
});
},1500); // 1500 ms seems to be just the right amount of delay to prevent data request timeouts
}
}
} else {
// try to catch part of valid CLI enter message // try to catch part of valid CLI enter message
this.validateText += String.fromCharCode(data[i]); validateText += currentChar;
text += String.fromCharCode(data[i]); writeToOutput(currentChar);
continue;
} }
const escapeSequenceCode = 27;
const escapeSequenceCharLength = 3;
if (data[i] == escapeSequenceCode && !sequenceCharsToSkip) { // ESC + other
sequenceCharsToSkip = escapeSequenceCharLength;
}
if (sequenceCharsToSkip) {
sequenceCharsToSkip--;
continue;
}
switch (data[i]) {
case lineFeedCode:
if (GUI.operating_system === "Windows") {
writeLineToOutput(this.cliBuffer);
this.cliBuffer = "";
}
break;
case carriageReturnCode:
if (GUI.operating_system !== "Windows") {
writeLineToOutput(this.cliBuffer);
this.cliBuffer = "";
}
break;
case 60:
this.cliBuffer += '&lt';
break;
case 62:
this.cliBuffer += '&gt';
break;
case backspaceCode:
this.cliBuffer = this.cliBuffer.slice(0, -1);
break;
default:
this.cliBuffer += currentChar;
}
this.outputHistory += currentChar;
if (this.cliBuffer == 'Rebooting') {
CONFIGURATOR.cliActive = false;
CONFIGURATOR.cliValid = false;
GUI.log(i18n.getMessage('cliReboot'));
GUI.log(i18n.getMessage('deviceRebooting'));
if (BOARD.find_board_definition(CONFIG.boardIdentifier).vcp) { // VCP-based flight controls may crash old drivers, we catch and reconnect
$('a.connect').click();
GUI.timeout_add('start_connection', function start_connection() {
$('a.connect').click();
}, 2500);
} else {
GUI.timeout_add('waiting_for_bootup', function waiting_for_bootup() {
MSP.send_message(MSPCodes.MSP_STATUS, false, false, function () {
GUI.log(i18n.getMessage('deviceReady'));
if (!GUI.tab_switch_in_progress) {
$('#tabs ul.mode-connected .tab_setup a').click();
}
});
}, 1500); // 1500 ms seems to be just the right amount of delay to prevent data request timeouts
}
}
} }
if (!CONFIGURATOR.cliValid && this.validateText.indexOf('CLI') != -1) { if (!CONFIGURATOR.cliValid && validateText.indexOf('CLI') !== -1) {
GUI.log(i18n.getMessage('cliEnter')); GUI.log(i18n.getMessage('cliEnter'));
CONFIGURATOR.cliValid = true; CONFIGURATOR.cliValid = true;
this.validateText = ""; validateText = "";
} }
$('.tab-cli .window .wrapper').append(text); setPrompt(removePromptHash(this.cliBuffer));
$('.tab-cli .window').scrollTop($('.tab-cli .window .wrapper').height());
}; };
TABS.cli.sendLine = function (line, callback) { TABS.cli.sendLine = function (line, callback) {
var bufferOut = new ArrayBuffer(line.length + 1); this.send(line + '\n', callback);
};
TABS.cli.sendAutoComplete = function (line, callback) {
this.send(line + '\t', callback);
};
TABS.cli.send = function (line, callback) {
var bufferOut = new ArrayBuffer(line.length);
var bufView = new Uint8Array(bufferOut); var bufView = new Uint8Array(bufferOut);
for (var c_key = 0; c_key < line.length; c_key++) { for (var c_key = 0; c_key < line.length; c_key++) {
bufView[c_key] = line.charCodeAt(c_key); bufView[c_key] = line.charCodeAt(c_key);
} }
bufView[line.length] = 0x0D; // enter (\n)
serial.send(bufferOut, callback); serial.send(bufferOut, callback);
} };
TABS.cli.cleanup = function (callback) { TABS.cli.cleanup = function (callback) {
if (!(CONFIGURATOR.connectionValid && CONFIGURATOR.cliValid && CONFIGURATOR.cliActive)) { if (!(CONFIGURATOR.connectionValid && CONFIGURATOR.cliValid && CONFIGURATOR.cliActive)) {
if (callback) callback(); if (callback) callback();
return; return;
} }
this.send(getCliCommand('exit\r', this.cliBuffer), function (writeInfo) {
var bufferOut = new ArrayBuffer(5);
var bufView = new Uint8Array(bufferOut);
bufView[0] = 0x65; // e
bufView[1] = 0x78; // x
bufView[2] = 0x69; // i
bufView[3] = 0x74; // t
bufView[4] = 0x0D; // enter
serial.send(bufferOut, function (writeInfo) {
// we could handle this "nicely", but this will do for now // we could handle this "nicely", but this will do for now
// (another approach is however much more complicated): // (another approach is however much more complicated):
// we can setup an interval asking for data lets say every 200ms, when data arrives, callback will be triggered and tab switched // we can setup an interval asking for data lets say every 200ms, when data arrives, callback will be triggered and tab switched

24
test/karma.conf.js Normal file
View file

@ -0,0 +1,24 @@
module.exports = function(config) {
config.set({
basePath: '../',
frameworks: ['mocha', 'chai', 'sinon-chai'],
files: [
'./libraries/jquery-2.1.4.min.js',
'./libraries/bluebird.min.js',
'./src/js/serial.js',
'./src/js/data_storage.js',
'./src/js/localization.js',
'./src/js/gui.js',
'./src/js/tabs/cli.js',
'./test/**/*.js'
],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox']
}
},
singleRun: true
});
};

264
test/tabs/cli.js Normal file
View file

@ -0,0 +1,264 @@
describe('TABS.cli', () => {
function toArrayBuffer(string) {
var bufferOut = new ArrayBuffer(string.length);
var bufView = new Uint8Array(bufferOut);
for (var i = 0; i < string.length; i++) {
bufView[i] = string.charCodeAt(i);
}
return bufferOut;
}
describe('output', () => {
const cliTab = $('<div>').addClass('tab-cli');
const cliOutput = $('<div>').addClass('wrapper')
const cliPrompt = $('<textarea>');
cliTab.append($('<div>').addClass('window').append(cliOutput));
cliTab.append(cliPrompt);
before(() => {
$('body')
.append(cliTab);
CONFIGURATOR.cliValid = true;
});
after(() => cliTab.remove());
beforeEach(() => {
cliOutput.empty();
cliPrompt.val('');
TABS.cli.cliBuffer = "";
});
it('ambiguous auto-complete results', () => {
TABS.cli.cliBuffer = 'se';
TABS.cli.read({
data: toArrayBuffer('\r\033[Kserialpassthrough\tservo\r\n# ser')
});
// Ambigous auto-complete from firmware is preceded with an \r carriage return
// which only renders a line break on Mac
const expectedValue = GUI.operating_system !== "Windows" ?
'se<br>serialpassthrough\tservo<br>' :
'seserialpassthrough\tservo<br>';
expect(cliOutput.html()).to.equal(expectedValue);
expect(cliPrompt.val()).to.equal('ser');
});
it('unambiguous auto-complete result', () => {
TABS.cli.read({
data: toArrayBuffer('serialpassthrough')
});
expect(cliOutput.html()).to.equal('');
expect(cliPrompt.val()).to.equal('serialpassthrough');
});
it('unambiguous auto-complete result with partial buffer', () => {
TABS.cli.cliBuffer = 'serial';
TABS.cli.read({
data: toArrayBuffer('passthrough')
});
expect(cliOutput.html()).to.equal('');
expect(cliPrompt.val()).to.equal('serialpassthrough');
});
it("escape characters (i.e. \033[K) are skipped", () => {
TABS.cli.read({
data: toArrayBuffer('\033[K')
});
expect(cliOutput.html()).to.equal('');
expect(cliPrompt.val()).to.equal('');
});
});
function triggerEnterKey(input) {
const enterKeycode = 13;
const event = $.Event("keypress");
event.which = enterKeycode;
input.trigger(event);
}
function triggerTabKey(input) {
const tabKeycode = 9;
const event = $.Event("keydown");
event.which = tabKeycode;
input.trigger(event);
}
const backspaceCode = String.fromCharCode(127);
describe('input', () => {
const content = $('<div>').attr('id', 'content');
const cliTab = $('<div>').addClass('tab-cli');
const cliPrompt = $('<textarea>');
cliTab.append(cliPrompt);
beforeEach(() => {
$('body')
.append(content);
// Stub loading of template.
sinon.stub($.fn, 'load').callsFake((file, callback) => {
content.append(cliTab);
callback();
});
sinon.stub(TABS.cli, 'send');
sinon.stub(Promise, 'reduce').callsFake((items, cb, initialValue) => {
items.forEach((line, idx) => cb(0, line, idx));
});
sinon.stub(window, 'Promise').callsFake(resolve => resolve(0));
sinon.stub(GUI, 'timeout_add').withArgs('CLI_send_slowly')
.callsFake((name, cb) => {
cb();
});
TABS.cli.cliBuffer = "";
});
afterEach(() => {
content.remove();
$.fn.load.restore();
TABS.cli.send.restore();
Promise.reduce.restore();
Promise.restore();
GUI.timeout_add.restore();
});
beforeEach(() => {
cliPrompt.val('');
content.empty();
});
it('tab key triggers serial message with appended tab char', done => {
TABS.cli.initialize(() => {
cliPrompt.val('serial');
triggerTabKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith('serial\t');
done()
});
});
it('second auto complete in row', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# ser';
cliPrompt.val('seri');
triggerTabKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith('i\t');
done();
});
});
it('auto-complete command with trailing space', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# get ';
cliPrompt.val('get r');
triggerTabKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith('r\t');
done();
});
});
it('auto-complete after delete characters', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# serial';
cliPrompt.val('ser');
triggerTabKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith(backspaceCode.repeat(3) + '\t');
done();
});
});
it('enter after autocomplete', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# servo';
cliPrompt.val('servo');
triggerEnterKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith('\n');
done();
});
});
it('enter after autocomplete', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# ser';
cliPrompt.val('servo');
triggerEnterKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith('vo\n');
done();
});
});
it('enter after deleting characters', done => {
TABS.cli.initialize(() => {
TABS.cli.cliBuffer = '# serial';
cliPrompt.val('ser');
triggerEnterKey(cliPrompt);
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith(backspaceCode.repeat(3) + '\n');
done();
});
});
it('cliBuffer is cleared on startup', done => {
TABS.cli.cliBuffer = '# serial';
TABS.cli.initialize(() => {
expect(TABS.cli.cliBuffer).to.equal('');
done();
});
});
it('exit upon cleanup clears cliBuffer first', done => {
CONFIGURATOR.connectionValid = true;
TABS.cli.cliValid = true;
TABS.cli.initialize(() => {
const commandInBuffer = 'resource';
TABS.cli.cliBuffer = `# ${commandInBuffer}`;
TABS.cli.cleanup();
expect(TABS.cli.send).to.have.been.calledOnce;
expect(TABS.cli.send).to.have.been.calledWith(backspaceCode.repeat(commandInBuffer.length) + 'exit\r');
done();
});
});
});
});