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
99 changes: 74 additions & 25 deletions addon/controllers/sockets/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,54 @@ export default class SocketsViewController extends BaseController {
return window.history.back();
}

/**
* Whether the last subscription attempt was refused. Cleared when the
* channel subscribes (the socket service resubscribes channels it lost
* for token reasons) and when leaving the page.
*
* @memberof SocketsViewController
*/
@tracked subscriptionFailed = false;

/**
* The "Awaiting events..." indicator only makes sense while the channel can
* still deliver something.
*
* @memberof SocketsViewController
*/
get isAwaitingEvents() {
return this.events.length > 0 && !this.subscriptionFailed;
}

/**
* Appends a line to the console output.
*
* @param {String} content
* @param {String} color tailwind color name
* @memberof SocketsViewController
*/
logEvent(content, color) {
// Reassigned rather than mutated so the tracked property invalidates.
this.events = [...this.events, { time: format(new Date(), this.consoleDateFormat), content, color }];
}

/**
* Describes a refused subscription. Authorization refusals from the socket
* server arrive as an `AuthError` with a short snake_case `reason`.
*
* @param {String} channelName
* @param {Error} error
* @return {String}
* @memberof SocketsViewController
*/
describeSubscribeFailure(channelName, error) {
if (error && error.name === 'AuthError') {
return this.intl.t('developers.sockets.view.socket-subscribe-denied', { modelName: channelName, reason: error.reason || error.message });
}

return this.intl.t('developers.sockets.view.socket-subscribe-failed', { modelName: channelName, message: error ? error.message : '' });
}

/**
* Opens socket and logs all incoming events.
*
Expand All @@ -56,59 +104,60 @@ export default class SocketsViewController extends BaseController {
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of socket.listener('error')) {
// Push an event or notification for socket connection here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-connection-error'),
color: 'red',
});
this.logEvent(this.intl.t('developers.sockets.view.socket-connection-error'), 'red');
}
})();

// Listen for socket connection
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of socket.listener('connect')) {
// Push an event or notification for socket connection here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-connected'),
color: 'green',
});
this.logEvent(this.intl.t('developers.sockets.view.socket-connected'), 'green');
}
})();

// Listed on company channel
// Subscribe to the channel
const channel = socket.subscribe(model.name);

// Listen for channel subscription
(async () => {
// eslint-disable-next-line no-unused-vars
for await (let event of channel.listener('subscribe')) {
// Push an event or notification for channel subscription here
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }),
color: 'blue',
});
this.subscriptionFailed = false;
this.logEvent(this.intl.t('developers.sockets.view.socket-subscribed', { modelName: model.name }), 'blue');
}
})();

// Listen for channel subscription
// Listen for a refused subscription, e.g. a channel this user is not
// authorized to see. Without this the page would wait silently forever.
(async () => {
for await (let { error } of channel.listener('subscribeFail')) {
this.subscriptionFailed = true;
this.logEvent(this.describeSubscribeFailure(model.name, error), 'red');
}
})();

// Listen for the server removing the subscription (for example when the
// socket token expired). The socket service resubscribes when it can, and
// the subscribe listener above logs that.
(async () => {
for await (let { message } of channel.listener('kickOut')) {
this.logEvent(this.intl.t('developers.sockets.view.socket-kicked-out', { modelName: model.name, reason: message }), 'red');
}
})();

// Listen for channel data
(async () => {
for await (let data of channel) {
this.events.pushObject({
time: format(new Date(), this.consoleDateFormat),
content: JSON.stringify(data, undefined, 2),
color: 'green',
});
this.logEvent(JSON.stringify(data, undefined, 2), 'green');
}
})();

// disconnect when transitioning
this.hostRouter.on('routeWillChange', () => {
channel.close();
this.events = [];
this.subscriptionFailed = false;
});
}
}
6 changes: 2 additions & 4 deletions addon/templates/sockets/view.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="block w-1/2 font-mono font-semibold text-green-300">
{{t "developers.sockets.view.output"}}
</div>
{{#if this.events.length}}
{{#if this.isAwaitingEvents}}
<div class="flex items-center justify-end w-1/2 text-sm text-green-300">
<Spinner @iconClass="green-loader" />

Expand All @@ -37,9 +37,7 @@
<div class="text-{{event.color}}-300 uppercase text-sm font-mono pr-2 flex-shrink-0">
[{{event.time}}]:
</div>
<div class="text-{{event.color}}-300 text-sm font-mono">
{{html-safe event.content}}
</div>
<div class="text-{{event.color}}-300 text-sm font-mono whitespace-pre-wrap break-all">{{event.content}}</div>
</div>
{{else}}
<div class="flex items-center mt-6 text-green-300">
Expand Down
189 changes: 186 additions & 3 deletions tests/unit/controllers/sockets/view-test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,195 @@
import { module, test } from 'qunit';
import { setupTest } from 'dummy/tests/helpers';

class IntlStub {
t(key, params) {
return params ? `${key} ${JSON.stringify(params)}` : key;
}
}

class HostRouterStub {
handlers = {};

on(eventName, handler) {
this.handlers[eventName] = handler;
}
}

class EmptyServiceStub {}

// An async-iterable event stream a test can push into.
function eventStream() {
const queue = [];
const waiting = [];

return {
push(value) {
if (waiting.length) {
waiting.shift()({ done: false, value });
} else {
queue.push(value);
}
},
close() {
while (waiting.length) {
waiting.shift()({ done: true, value: undefined });
}
},
[Symbol.asyncIterator]() {
return {
next: () => (queue.length ? Promise.resolve({ done: false, value: queue.shift() }) : new Promise((resolve) => waiting.push(resolve))),
};
},
};
}

function fakeChannel() {
const listeners = {};
const data = eventStream();

return {
closed: false,
listener(eventName) {
listeners[eventName] = listeners[eventName] || eventStream();
return listeners[eventName];
},
emit(eventName, value) {
this.listener(eventName).push(value);
},
publish(value) {
data.push(value);
},
close() {
this.closed = true;
Object.values(listeners).forEach((stream) => stream.close());
data.close();
},
[Symbol.asyncIterator]() {
return data[Symbol.asyncIterator]();
},
};
}

// Stands in for the ember-core socket service: `instance()` returns a client
// whose subscribe hands back the test's channel.
class SocketStub {
channel = null;
subscribed = [];
clientListeners = {};

instance() {
return {
listener: (eventName) => {
this.clientListeners[eventName] = this.clientListeners[eventName] || eventStream();
return this.clientListeners[eventName];
},
subscribe: (name) => {
this.subscribed.push(name);
return this.channel;
},
};
}
}

function authError(reason) {
const error = new Error(`Subscription to channel denied: ${reason}`);
error.name = 'AuthError';
error.reason = reason;
return error;
}

const flush = () => new Promise((resolve) => setTimeout(resolve, 0));

module('Unit | Controller | sockets/view', function (hooks) {
setupTest(hooks);

// TODO: Replace this with your real tests.
hooks.beforeEach(function () {
this.owner.register('service:intl', IntlStub);
this.owner.register('service:host-router', HostRouterStub);
this.owner.register('service:socket', SocketStub);
this.owner.register('service:universe', EmptyServiceStub);

this.controller = this.owner.lookup('controller:sockets/view');
this.channel = fakeChannel();
this.socket = this.owner.lookup('service:socket');
this.socket.channel = this.channel;
});

test('it exists', function (assert) {
let controller = this.owner.lookup('controller:sockets/view');
assert.ok(controller);
assert.ok(this.controller);
});

test('an authorization refusal is shown with its reason', function (assert) {
const message = this.controller.describeSubscribeFailure('order.abc', authError('forbidden'));

assert.true(message.startsWith('developers.sockets.view.socket-subscribe-denied'));
assert.true(message.includes('"reason":"forbidden"'));
});

test('an authorization refusal without a reason falls back to the message', function (assert) {
const error = authError(undefined);
const message = this.controller.describeSubscribeFailure('order.abc', error);

assert.true(message.includes(JSON.stringify(error.message).slice(1, -1)));
});

test('any other refusal is shown with its message', function (assert) {
const message = this.controller.describeSubscribeFailure('order.abc', new Error('Socket hung up'));

assert.true(message.startsWith('developers.sockets.view.socket-subscribe-failed'));
assert.true(message.includes('Socket hung up'));
assert.true(this.controller.describeSubscribeFailure('order.abc', null).startsWith('developers.sockets.view.socket-subscribe-failed'));
});

test('a refused subscription is logged and stops the awaiting indicator', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });
assert.deepEqual(this.socket.subscribed, ['order.abc']);

this.channel.emit('subscribeFail', { error: authError('forbidden') });
await flush();

assert.true(this.controller.subscriptionFailed);
assert.false(this.controller.isAwaitingEvents);
assert.strictEqual(this.controller.events.length, 1);
assert.strictEqual(this.controller.events[0].color, 'red');
assert.true(this.controller.events[0].content.startsWith('developers.sockets.view.socket-subscribe-denied'));
});

test('a kick-out is logged and a later resubscribe clears the failure', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });

this.channel.emit('subscribeFail', { error: authError('token_expired') });
this.channel.emit('kickOut', { message: 'token_expired' });
await flush();
this.channel.emit('subscribe', {});
await flush();

assert.false(this.controller.subscriptionFailed);
assert.true(this.controller.isAwaitingEvents);
assert.deepEqual(
this.controller.events.map((event) => event.content.split(' ')[0]),
['developers.sockets.view.socket-subscribe-denied', 'developers.sockets.view.socket-kicked-out', 'developers.sockets.view.socket-subscribed']
);
});

test('channel data is logged as JSON', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });

this.channel.publish({ event: 'order.updated' });
await flush();

assert.strictEqual(this.controller.events[0].content, JSON.stringify({ event: 'order.updated' }, undefined, 2));
});

test('leaving the page closes the channel and resets the output', async function (assert) {
this.controller.watchSocket({ name: 'order.abc' });
this.channel.emit('subscribeFail', { error: authError('forbidden') });
await flush();

this.owner.lookup('service:host-router').handlers.routeWillChange();

assert.true(this.channel.closed);
assert.deepEqual(this.controller.events, []);
assert.false(this.controller.subscriptionFailed);
});
});
3 changes: 3 additions & 0 deletions translations/ar-ae.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ developers:
socket-connection-error: خطأ في اتصال المقبس!
socket-connected: المقبس متصل
socket-subscribed: المقبس مشترك في القناة {modelName}
socket-subscribe-denied: غير مصرح بالاشتراك في القناة {modelName} ({reason})
socket-subscribe-failed: "تعذر الاشتراك في القناة {modelName}: {message}"
socket-kicked-out: تمت الإزالة من القناة {modelName} ({reason})
output: الإخراج
awaiting-events: في انتظار الأحداث...
webhooks:
Expand Down
3 changes: 3 additions & 0 deletions translations/bg-bg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ developers:
socket-connection-error: Грешка при свързване на сокет!
socket-connected: Сокетът е свързан
socket-subscribed: Сокетът е абониран за канал {modelName}
socket-subscribe-denied: Няма разрешение за абониране за канал {modelName} ({reason})
socket-subscribe-failed: "Неуспешно абониране за канал {modelName}: {message}"
socket-kicked-out: Премахнат от канал {modelName} ({reason})
output: Изход
awaiting-events: Очакване на събития...
webhooks:
Expand Down
3 changes: 3 additions & 0 deletions translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ developers:
socket-connection-error: Socket connection error!
socket-connected: Socket is connected
socket-subscribed: Socket subscribed to channel {modelName}
socket-subscribe-denied: Not authorized to subscribe to channel {modelName} ({reason})
socket-subscribe-failed: "Could not subscribe to channel {modelName}: {message}"
socket-kicked-out: Removed from channel {modelName} ({reason})
output: Output
awaiting-events: Awaiting events...
webhooks:
Expand Down
3 changes: 3 additions & 0 deletions translations/es-es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ developers:
socket-connection-error: ¡Error de conexión de socket!
socket-connected: Socket conectado
socket-subscribed: Socket suscrito al canal {modelName}
socket-subscribe-denied: No autorizado para suscribirse al canal {modelName} ({reason})
socket-subscribe-failed: "No se pudo suscribir al canal {modelName}: {message}"
socket-kicked-out: Eliminado del canal {modelName} ({reason})
output: Salida
awaiting-events: Esperando eventos...
webhooks:
Expand Down
Loading
Loading