Commit cb02db42 authored by Phil Hughes's avatar Phil Hughes

Merge branch '5276-3-update-ide-diff-and-mirror-modules' into 'master'

[Part 3] 5276 Update file mirror and diff modules

See merge request gitlab-org/gitlab-ee!14033
parents e23e189e f0a761b8
import { join as joinPaths } from 'path';
// Returns an array containing the value(s) of the // Returns an array containing the value(s) of the
// of the key passed as an argument // of the key passed as an argument
export function getParameterValues(sParam) { export function getParameterValues(sParam) {
...@@ -157,4 +159,12 @@ export function isSafeURL(url) { ...@@ -157,4 +159,12 @@ export function isSafeURL(url) {
} }
} }
export { join as joinPaths } from 'path'; export function getWebSocketProtocol() {
return window.location.protocol.replace('http', 'ws');
}
export function getWebSocketUrl(path) {
return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`;
}
export { joinPaths };
...@@ -2,7 +2,17 @@ import { commitActionForFile } from '~/ide/stores/utils'; ...@@ -2,7 +2,17 @@ import { commitActionForFile } from '~/ide/stores/utils';
import { commitActionTypes } from '~/ide/constants'; import { commitActionTypes } from '~/ide/constants';
import createFileDiff from './create_file_diff'; import createFileDiff from './create_file_diff';
const filesWithChanges = ({ stagedFiles = [], changedFiles = [] }) => { const getDeletedParents = (entries, file) => {
const parent = file.parentPath && entries[file.parentPath];
if (parent && parent.deleted) {
return [parent, ...getDeletedParents(entries, parent)];
}
return [];
};
const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => {
// We need changed files to overwrite staged, so put them at the end. // We need changed files to overwrite staged, so put them at the end.
const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => { const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
const key = file.path; const key = file.path;
...@@ -39,6 +49,20 @@ const filesWithChanges = ({ stagedFiles = [], changedFiles = [] }) => { ...@@ -39,6 +49,20 @@ const filesWithChanges = ({ stagedFiles = [], changedFiles = [] }) => {
} }
}); });
// Next, we need to add deleted directories by looking at the parents
Object.values(changes)
.filter(change => change.action === commitActionTypes.delete && change.file.parentPath)
.forEach(({ file }) => {
// Do nothing if we've already visited this directory.
if (changes[file.parentPath]) {
return;
}
getDeletedParents(entries, file).forEach(parent => {
changes[parent.path] = { action: commitActionTypes.delete, file: parent };
});
});
return Object.values(changes); return Object.values(changes);
}; };
......
import _ from 'underscore';
import createDiff from './create_diff'; import createDiff from './create_diff';
import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
export const SERVICE_NAME = 'webide-file-sync';
export const PROTOCOL = 'webfilesync.gitlab.com';
export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.');
// Before actually connecting to the service, we must delay a bit
// so that the service has sufficiently started.
export const SERVICE_DELAY = 8000;
const cancellableWait = time => {
let timeoutId = 0;
const cancel = () => clearTimeout(timeoutId);
const promise = new Promise(resolve => {
timeoutId = setTimeout(resolve, time);
});
return [promise, cancel];
};
const isErrorResponse = error => error && error.code !== 0;
const isErrorPayload = payload => payload && payload.status_code !== 200;
const getErrorFromResponse = data => {
if (isErrorResponse(data.error)) {
return { message: data.error.Message };
} else if (isErrorPayload(data.payload)) {
return { message: data.payload.error_message };
}
return null;
};
const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path));
const createWebSocket = fullPath =>
new Promise((resolve, reject) => {
const socket = new WebSocket(fullPath, [PROTOCOL]);
const resetCallbacks = () => {
socket.onopen = null;
socket.onerror = null;
};
socket.onopen = () => {
resetCallbacks();
resolve(socket);
};
socket.onerror = () => {
resetCallbacks();
reject(new Error(MSG_CONNECTION_ERROR));
};
});
export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME);
export const createMirror = () => { export const createMirror = () => {
const uploadDiff = () => { let socket = null;
// For now, this is a placeholder. let cancelHandler = _.noop;
// It will be implemented in https://gitlab.com/gitlab-org/gitlab-ee/issues/5276 let nextMessageHandler = _.noop;
const cancelConnect = () => {
cancelHandler();
cancelHandler = _.noop;
};
const onCancelConnect = fn => {
cancelHandler = fn;
};
const receiveMessage = ev => {
const handle = nextMessageHandler;
nextMessageHandler = _.noop;
handle(JSON.parse(ev.data));
};
const onNextMessage = fn => {
nextMessageHandler = fn;
};
const waitForNextMessage = () =>
new Promise((resolve, reject) => {
onNextMessage(data => {
const err = getErrorFromResponse(data);
if (err) {
reject(err);
} else {
resolve();
}
});
});
const uploadDiff = ({ toDelete, patch }) => {
if (!socket) {
return Promise.resolve();
}
const response = waitForNextMessage();
const msg = {
code: 'EVENT',
namespace: '/files',
event: 'PATCH',
payload: { diff: patch, delete_files: toDelete },
};
socket.send(JSON.stringify(msg));
return response;
}; };
return { return {
upload(state) { upload(state) {
uploadDiff(createDiff(state)); return uploadDiff(createDiff(state));
},
connect(path) {
if (socket) {
this.disconnect();
}
const fullPath = getFullPath(path);
const [wait, cancelWait] = cancellableWait(SERVICE_DELAY);
onCancelConnect(cancelWait);
return wait
.then(() => createWebSocket(fullPath))
.then(newSocket => {
socket = newSocket;
socket.onmessage = receiveMessage;
});
},
disconnect() {
cancelConnect();
if (!socket) {
return;
}
socket.close();
socket = null;
}, },
}; };
}; };
......
...@@ -25,3 +25,11 @@ export const createMovedFile = (path, prevPath, content) => ...@@ -25,3 +25,11 @@ export const createMovedFile = (path, prevPath, content) =>
Object.assign(createNewFile(path, content), { Object.assign(createNewFile(path, content), {
prevPath, prevPath,
}); });
export const createEntries = path =>
path.split('/').reduce((acc, part, idx, parts) => {
const parentPath = parts.slice(0, idx).join('/');
const fullPath = parentPath ? `${parentPath}/${part}` : part;
return Object.assign(acc, { [fullPath]: { ...createFile(fullPath), parentPath } });
}, {});
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
createUpdatedFile, createUpdatedFile,
createDeletedFile, createDeletedFile,
createMovedFile, createMovedFile,
createEntries,
} from '../file_helpers'; } from '../file_helpers';
const PATH_FOO = 'test/foo.md'; const PATH_FOO = 'test/foo.md';
...@@ -161,4 +162,21 @@ ${LINES.map(line => `-${line}`).join('\n')} ...@@ -161,4 +162,21 @@ ${LINES.map(line => `-${line}`).join('\n')}
toDelete: [PATH_ZED], toDelete: [PATH_ZED],
}); });
}); });
it('deletes deleted parent directories', () => {
const deletedFiles = ['foo/bar/zed/test.md', 'foo/bar/zed/test2.md'];
const entries = deletedFiles.reduce((acc, path) => Object.assign(acc, createEntries(path)), {});
const allDeleted = [...deletedFiles, 'foo/bar/zed', 'foo/bar'];
allDeleted.forEach(path => {
entries[path].deleted = true;
});
const changedFiles = deletedFiles.map(x => entries[x]);
const result = createDiff({ changedFiles, entries });
expect(result).toEqual({
patch: '',
toDelete: allDeleted,
});
});
}); });
import { getWebSocketUrl } from '~/lib/utils/url_utility';
import createDiff from 'ee/ide/lib/create_diff';
import {
canConnect,
createMirror,
SERVICE_NAME,
PROTOCOL,
MSG_CONNECTION_ERROR,
SERVICE_DELAY,
} from 'ee/ide/lib/mirror';
jest.mock('ee/ide/lib/create_diff', () => jest.fn());
const TEST_PATH = '/project/ide/proxy/path';
const TEST_DIFF = {
patch: 'lorem ipsum',
toDelete: ['foo.md'],
};
const TEST_ERROR = 'Something bad happened...';
const TEST_SUCCESS_RESPONSE = {
data: JSON.stringify({ error: { code: 0 }, payload: { status_code: 200 } }),
};
const TEST_ERROR_RESPONSE = {
data: JSON.stringify({ error: { code: 1, Message: TEST_ERROR }, payload: { status_code: 200 } }),
};
const TEST_ERROR_PAYLOAD_RESPONSE = {
data: JSON.stringify({
error: { code: 0 },
payload: { status_code: 500, error_message: TEST_ERROR },
}),
};
const buildUploadMessage = ({ toDelete, patch }) =>
JSON.stringify({
code: 'EVENT',
namespace: '/files',
event: 'PATCH',
payload: { diff: patch, delete_files: toDelete },
});
describe('ee/ide/lib/mirror', () => {
describe('canConnect', () => {
it('can connect if the session has the expected service', () => {
const result = canConnect({ services: ['test1', SERVICE_NAME, 'test2'] });
expect(result).toBe(true);
});
it('cannot connect if the session does not have the expected service', () => {
const result = canConnect({ services: ['test1', 'test2'] });
expect(result).toBe(false);
});
});
describe('createMirror', () => {
const origWebSocket = global.WebSocket;
let mirror;
let mockWebSocket;
beforeEach(() => {
mockWebSocket = {
close: jest.fn(),
send: jest.fn(),
};
global.WebSocket = jest.fn().mockImplementation(() => mockWebSocket);
mirror = createMirror();
});
afterEach(() => {
global.WebSocket = origWebSocket;
});
const waitForConnection = (delay = SERVICE_DELAY) => {
const wait = new Promise(resolve => {
setTimeout(resolve, 10);
});
jest.advanceTimersByTime(delay);
return wait;
};
const connectPass = () => waitForConnection().then(() => mockWebSocket.onopen());
const connectFail = () => waitForConnection().then(() => mockWebSocket.onerror());
const sendResponse = msg => {
mockWebSocket.onmessage(msg);
};
describe('connect', () => {
let connection;
beforeEach(() => {
connection = mirror.connect(TEST_PATH);
});
it('waits before creating web socket', () => {
// ignore error when test suite terminates
connection.catch(() => {});
return waitForConnection(SERVICE_DELAY - 10).then(() => {
expect(global.WebSocket).not.toHaveBeenCalled();
});
});
it('is canceled when disconnected before finished waiting', () => {
mirror.disconnect();
return waitForConnection(SERVICE_DELAY).then(() => {
expect(global.WebSocket).not.toHaveBeenCalled();
});
});
describe('when connection is successful', () => {
beforeEach(connectPass);
it('connects to service', () => {
const expectedPath = `${getWebSocketUrl(TEST_PATH)}?service=${SERVICE_NAME}`;
return connection.then(() => {
expect(global.WebSocket).toHaveBeenCalledWith(expectedPath, [PROTOCOL]);
});
});
it('disconnects when connected again', () => {
const result = connection
.then(() => {
mirror.connect(TEST_PATH).catch(() => {});
})
.then(() => {
expect(mockWebSocket.close).toHaveBeenCalled();
});
return result;
});
});
describe('when connection fails', () => {
beforeEach(connectFail);
it('rejects with error', () => {
expect(connection).rejects.toEqual(new Error(MSG_CONNECTION_ERROR));
});
});
});
describe('upload', () => {
let state;
beforeEach(() => {
state = { changedFiles: [] };
createDiff.mockReturnValue(TEST_DIFF);
const connection = mirror.connect(TEST_PATH);
return connectPass().then(() => connection);
});
it('creates a diff from the given state', () => {
const result = mirror.upload(state);
sendResponse(TEST_SUCCESS_RESPONSE);
return result.then(() => {
expect(createDiff).toHaveBeenCalledWith(state);
expect(mockWebSocket.send).toHaveBeenCalledWith(buildUploadMessage(TEST_DIFF));
});
});
it.each`
response | description
${TEST_ERROR_RESPONSE} | ${'error in error'}
${TEST_ERROR_PAYLOAD_RESPONSE} | ${'error in payload'}
`('rejects if response has $description', ({ response }) => {
const result = mirror.upload(state);
sendResponse(response);
return expect(result).rejects.toEqual({ message: TEST_ERROR });
});
});
});
});
...@@ -3634,6 +3634,9 @@ msgstr "" ...@@ -3634,6 +3634,9 @@ msgstr ""
msgid "Could not connect to FogBugz, check your URL" msgid "Could not connect to FogBugz, check your URL"
msgstr "" msgstr ""
msgid "Could not connect to Web IDE file mirror service."
msgstr ""
msgid "Could not create Wiki Repository at this time. Please try again later." msgid "Could not create Wiki Repository at this time. Please try again later."
msgstr "" msgstr ""
......
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
const setWindowLocation = value => {
Object.defineProperty(window, 'location', {
writable: true,
value,
});
};
describe('URL utility', () => { describe('URL utility', () => {
describe('webIDEUrl', () => { describe('webIDEUrl', () => {
afterEach(() => { afterEach(() => {
...@@ -110,12 +117,9 @@ describe('URL utility', () => { ...@@ -110,12 +117,9 @@ describe('URL utility', () => {
describe('getBaseURL', () => { describe('getBaseURL', () => {
beforeEach(() => { beforeEach(() => {
global.window = Object.create(window); setWindowLocation({
Object.defineProperty(window, 'location', { protocol: 'https:',
value: { host: 'gitlab.com',
host: 'gitlab.com',
protocol: 'https:',
},
}); });
}); });
...@@ -191,4 +195,32 @@ describe('URL utility', () => { ...@@ -191,4 +195,32 @@ describe('URL utility', () => {
}); });
}); });
}); });
describe('getWebSocketProtocol', () => {
it.each`
protocol | expectation
${'http:'} | ${'ws:'}
${'https:'} | ${'wss:'}
`('returns "$expectation" with "$protocol" protocol', ({ protocol, expectation }) => {
setWindowLocation({
protocol,
host: 'example.com',
});
expect(urlUtils.getWebSocketProtocol()).toEqual(expectation);
});
});
describe('getWebSocketUrl', () => {
it('joins location host to path', () => {
setWindowLocation({
protocol: 'http:',
host: 'example.com',
});
const path = '/lorem/ipsum?a=bc';
expect(urlUtils.getWebSocketUrl(path)).toEqual('ws://example.com/lorem/ipsum?a=bc');
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment