Add mocking for peerJS and first test
This commit is contained in:
parent
5c65ff1900
commit
ebf846c154
13 changed files with 375 additions and 17 deletions
|
@ -5,3 +5,5 @@ Work in progress name, work in progress game
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.
|
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.
|
||||||
|
|
||||||
|
PeerJS mocking is based on works by Rolf Erik Lekang, which is licensed under MIT. Check [peerjs-mock](https://github.com/relekang/peerjs-mock) for more details.
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"buefy": "^0.8.2",
|
"buefy": "^0.8.2",
|
||||||
"core-js": "^2.6.5",
|
"core-js": "^2.6.5",
|
||||||
"dexie": "^2.0.4",
|
"dexie": "^2.0.4",
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
"peerjs": "^1.0.4",
|
"peerjs": "^1.0.4",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
"vue": "^2.6.10",
|
"vue": "^2.6.10",
|
||||||
|
|
|
@ -1,21 +1,26 @@
|
||||||
import NetworkPeer from "./peer";
|
import NetworkPeer from "./peer";
|
||||||
import { DataConnection } from "peerjs";
|
import Peer, { DataConnection } from "peerjs";
|
||||||
import { PeerMetadata, NetworkMessage } from "./types";
|
import { PeerMetadata, NetworkMessage } from "./types";
|
||||||
|
|
||||||
export default class PeerClient extends NetworkPeer {
|
export class PeerClient extends NetworkPeer {
|
||||||
private connection: DataConnection;
|
private connection: DataConnection;
|
||||||
public constructor(peerid: string, metadata: PeerMetadata) {
|
public constructor(
|
||||||
super();
|
peerid: string,
|
||||||
|
metadata: PeerMetadata,
|
||||||
|
customPeer?: Peer
|
||||||
|
) {
|
||||||
|
super(customPeer);
|
||||||
this.connection = this.peer.connect(peerid, {
|
this.connection = this.peer.connect(peerid, {
|
||||||
label: "server",
|
|
||||||
metadata,
|
metadata,
|
||||||
reliable: true
|
reliable: true
|
||||||
});
|
});
|
||||||
this.connection.on("open", () => {
|
this.connection.on("open", () => {
|
||||||
console.info("Connected to server");
|
console.info("Connected to server");
|
||||||
});
|
});
|
||||||
this.connection.on("data", this._received);
|
this.connection.on("data", this._received.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _received(data: NetworkMessage) {}
|
private _received(data: NetworkMessage) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PeerClient;
|
||||||
|
|
5
src/network/index.ts
Normal file
5
src/network/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./client";
|
||||||
|
export * from "./local";
|
||||||
|
export * from "./peer";
|
||||||
|
export * from "./server";
|
||||||
|
export * from "./types";
|
|
@ -1,6 +1,6 @@
|
||||||
import { PeerMetadata, NetworkMessage } from "./types";
|
import { PeerMetadata, NetworkMessage } from "./types";
|
||||||
|
|
||||||
export default class LocalClient {
|
export class LocalClient {
|
||||||
public metadata: PeerMetadata;
|
public metadata: PeerMetadata;
|
||||||
public receiver!: (data: NetworkMessage) => void;
|
public receiver!: (data: NetworkMessage) => void;
|
||||||
|
|
||||||
|
@ -16,3 +16,5 @@ export default class LocalClient {
|
||||||
this.receiver(data);
|
this.receiver(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LocalClient;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import Peer, { DataConnection } from "peerjs";
|
import Peer, { DataConnection } from "peerjs";
|
||||||
import { NetworkMessage } from "./types";
|
import { NetworkMessage } from "./types";
|
||||||
|
|
||||||
export default class NetworkPeer {
|
export class NetworkPeer {
|
||||||
protected peer: Peer;
|
protected peer: Peer;
|
||||||
protected constructor() {
|
protected constructor(peer?: Peer) {
|
||||||
this.peer = new Peer();
|
this.peer = peer ? peer : new Peer();
|
||||||
this.peer.on("open", function(id) {
|
this.peer.on("open", function(id) {
|
||||||
console.info("Peer ID assigned: %s", id);
|
console.info("Peer ID assigned: %s", id);
|
||||||
});
|
});
|
||||||
|
@ -15,3 +15,5 @@ export default class NetworkPeer {
|
||||||
conn.send(data);
|
conn.send(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default NetworkPeer;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import NetworkPeer from "./peer";
|
import NetworkPeer from "./peer";
|
||||||
import { DataConnection } from "peerjs";
|
import Peer, { DataConnection } from "peerjs";
|
||||||
import {
|
import {
|
||||||
RoomInfo,
|
RoomInfo,
|
||||||
PasswordRequest,
|
PasswordRequest,
|
||||||
|
@ -33,11 +33,15 @@ function nextName(name: string): string {
|
||||||
return name.substr(0, i) + (Number(name.slice(i)) + 1);
|
return name.substr(0, i) + (Number(name.slice(i)) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class PeerServer extends NetworkPeer {
|
export class PeerServer extends NetworkPeer {
|
||||||
private room: Room;
|
private room: Room;
|
||||||
|
|
||||||
public constructor(roomInfo: RoomInfo, local: LocalClient) {
|
public constructor(
|
||||||
super();
|
roomInfo: RoomInfo,
|
||||||
|
local: LocalClient,
|
||||||
|
customPeer?: Peer
|
||||||
|
) {
|
||||||
|
super(customPeer);
|
||||||
let players: Record<string, Player> = {};
|
let players: Record<string, Player> = {};
|
||||||
|
|
||||||
// Add local player to server
|
// Add local player to server
|
||||||
|
@ -52,7 +56,7 @@ export default class PeerServer extends NetworkPeer {
|
||||||
info: roomInfo,
|
info: roomInfo,
|
||||||
players
|
players
|
||||||
};
|
};
|
||||||
this.peer.on("connection", this._connection);
|
this.peer.on("connection", this._connection.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _connection(conn: DataConnection) {
|
private _connection(conn: DataConnection) {
|
||||||
|
@ -105,7 +109,7 @@ export default class PeerServer extends NetworkPeer {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.send<PasswordRequest>(conn, { kind: "password-req" });
|
this.send<PasswordRequest>(conn, { kind: "password-req" });
|
||||||
conn.on("data", checkPasswordResponse);
|
conn.on("data", checkPasswordResponse.bind(this));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,3 +188,5 @@ export default class PeerServer extends NetworkPeer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PeerServer;
|
||||||
|
|
|
@ -27,7 +27,7 @@ export interface Room {
|
||||||
export interface RoomInfo {
|
export interface RoomInfo {
|
||||||
max_players: number;
|
max_players: number;
|
||||||
password: string;
|
password: string;
|
||||||
game: GameInfo;
|
game?: GameInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameInfo = MatchInfo | DraftInfo;
|
type GameInfo = MatchInfo | DraftInfo;
|
||||||
|
|
219
src/testing/LocalDataConnection.ts
Normal file
219
src/testing/LocalDataConnection.ts
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
import LocalPeer from "./LocalPeer";
|
||||||
|
import { PeerConnectOption } from "peerjs";
|
||||||
|
|
||||||
|
export default class LocalDataConnection extends EventEmitter {
|
||||||
|
public options: PeerConnectOption;
|
||||||
|
public peer: string;
|
||||||
|
public provider: LocalPeer;
|
||||||
|
public open: boolean = false;
|
||||||
|
private _connection?: LocalDataConnection;
|
||||||
|
public id: string;
|
||||||
|
public label: string;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
peer: string,
|
||||||
|
provider: LocalPeer,
|
||||||
|
options?: PeerConnectOption
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.options = {
|
||||||
|
serialization: "binary",
|
||||||
|
reliable: false
|
||||||
|
};
|
||||||
|
if (options) {
|
||||||
|
this.options = Object.assign(this.options, options);
|
||||||
|
}
|
||||||
|
this.id = "fake_" + Math.random().toString(32);
|
||||||
|
this.label = this.options.label ? this.options.label : this.id;
|
||||||
|
this.provider = provider;
|
||||||
|
this.peer = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public send(message: any) {
|
||||||
|
if (!this.open) {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
new Error(
|
||||||
|
"Connection is not open. You should listen for the `open` event before sending messages."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const connection = this._connection;
|
||||||
|
if (connection) {
|
||||||
|
connection.receive(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public receive(message: any) {
|
||||||
|
this.emit("data", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
if (!this.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.open = false;
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
public _setRemote(connection: LocalDataConnection) {
|
||||||
|
this._connection = connection;
|
||||||
|
this._connection.on("open", () => {
|
||||||
|
if (!this.open) {
|
||||||
|
this.open = true;
|
||||||
|
this.emit("open");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public _configureDataChannel(
|
||||||
|
mocks: Record<string, LocalPeer>,
|
||||||
|
options?: PeerConnectOption
|
||||||
|
) {
|
||||||
|
if (!this._connection && this.peer in mocks) {
|
||||||
|
this._setRemote(
|
||||||
|
mocks[this.peer]._negotiate(this.provider.id, this, options)
|
||||||
|
);
|
||||||
|
this.open = true;
|
||||||
|
this.emit("open");
|
||||||
|
} else {
|
||||||
|
throw new Error(`Peer(${this.peer}) not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get metadata() {
|
||||||
|
return this.options.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get reliable() {
|
||||||
|
return this.options.reliable ? this.options.reliable : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get serialization() {
|
||||||
|
return this.options.serialization ? this.options.serialization : "binary";
|
||||||
|
}
|
||||||
|
|
||||||
|
public get peerConnection() {
|
||||||
|
return this._connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
UNIMPLEMENTED STUFF
|
||||||
|
*/
|
||||||
|
|
||||||
|
public dataChannel: unknown;
|
||||||
|
public bufferSize: unknown;
|
||||||
|
public type: unknown;
|
||||||
|
public canTrickleIceCandidates: unknown;
|
||||||
|
public connectionState: unknown;
|
||||||
|
public currentLocalDescription: unknown;
|
||||||
|
public currentRemoteDescription: unknown;
|
||||||
|
public iceConnectionState: unknown;
|
||||||
|
public iceGatheringState: unknown;
|
||||||
|
public idpErrorInfo: unknown;
|
||||||
|
public idpLoginUrl: unknown;
|
||||||
|
public localDescription: unknown;
|
||||||
|
public onconnectionstatechange: unknown;
|
||||||
|
public ondatachannel: unknown;
|
||||||
|
public onicecandidate: unknown;
|
||||||
|
public onicecandidateerror: unknown;
|
||||||
|
public oniceconnectionstatechange: unknown;
|
||||||
|
public onicegatheringstatechange: unknown;
|
||||||
|
public onnegotiationneeded: unknown;
|
||||||
|
public onsignalingstatechange: unknown;
|
||||||
|
public onstatsended: unknown;
|
||||||
|
public ontrack: unknown;
|
||||||
|
public peerIdentity: unknown;
|
||||||
|
public pendingLocalDescription: unknown;
|
||||||
|
public pendingRemoteDescription: unknown;
|
||||||
|
public remoteDescription: unknown;
|
||||||
|
public sctp: unknown;
|
||||||
|
public signalingState: unknown;
|
||||||
|
|
||||||
|
public addIceCandidate(
|
||||||
|
candidate: RTCIceCandidateInit | RTCIceCandidate
|
||||||
|
): Promise<void> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): unknown {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public addTransceiver(
|
||||||
|
trackOrKind: MediaStreamTrack | string,
|
||||||
|
init?: RTCRtpTransceiverInit
|
||||||
|
): unknown {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public createAnswer(options?: RTCOfferOptions): Promise<unknown> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public createDataChannel(
|
||||||
|
label: string,
|
||||||
|
dataChannelDict?: RTCDataChannelInit
|
||||||
|
): unknown {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public createOffer(options?: RTCOfferOptions): Promise<unknown> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getConfiguration(): unknown {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getIdentityAssertion(): Promise<string> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getReceivers(): unknown[] {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getSenders(): unknown[] {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public getTransceivers(): unknown[] {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public removeTrack(sender: RTCRtpSender): void {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public setConfiguration(configuration: RTCConfiguration): void {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public setIdentityProvider(
|
||||||
|
provider: string,
|
||||||
|
options?: RTCIdentityProviderOptions
|
||||||
|
) {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public setLocalDescription(
|
||||||
|
description: RTCSessionDescriptionInit
|
||||||
|
): Promise<void> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
public setRemoteDescription(
|
||||||
|
description: RTCSessionDescriptionInit
|
||||||
|
): Promise<void> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
removeEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject,
|
||||||
|
options?: boolean | EventListenerOptions
|
||||||
|
): void {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event: Event): boolean {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
}
|
88
src/testing/LocalPeer.ts
Normal file
88
src/testing/LocalPeer.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
import Peer, { PeerConnectOption, PeerJSOption } from "peerjs";
|
||||||
|
import LocalDataConnection from "./LocalDataConnection";
|
||||||
|
|
||||||
|
let mocks: Record<string, LocalPeer> = {};
|
||||||
|
|
||||||
|
export default class LocalPeer extends EventEmitter {
|
||||||
|
public connections: Record<string, LocalDataConnection[]>;
|
||||||
|
public id: string;
|
||||||
|
public options: PeerJSOption;
|
||||||
|
public disconnected: boolean;
|
||||||
|
public destroyed: boolean;
|
||||||
|
public prototype: unknown;
|
||||||
|
|
||||||
|
public constructor(id: string, options?: PeerJSOption) {
|
||||||
|
super();
|
||||||
|
this.id = id;
|
||||||
|
this.options = options ? options : {};
|
||||||
|
this.connections = {};
|
||||||
|
this.disconnected = false;
|
||||||
|
this.destroyed = false;
|
||||||
|
mocks[id] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect(id: string, options?: PeerConnectOption): LocalDataConnection {
|
||||||
|
let connection = new LocalDataConnection(id, this, options);
|
||||||
|
connection._configureDataChannel(mocks, options);
|
||||||
|
this._addConnection(id, connection);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnect() {
|
||||||
|
this.disconnected = true;
|
||||||
|
this.emit("disconnected", this.id);
|
||||||
|
this.id = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.emit("destroyed", this.id);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addConnection(peer: string, connection: LocalDataConnection) {
|
||||||
|
if (!this.connections[peer]) {
|
||||||
|
this.connections[peer] = [];
|
||||||
|
}
|
||||||
|
this.connections[peer].push(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public _negotiate(
|
||||||
|
peer: string,
|
||||||
|
remoteConnection: LocalDataConnection,
|
||||||
|
options?: PeerConnectOption
|
||||||
|
) {
|
||||||
|
let localConnection = new LocalDataConnection(peer, this, options);
|
||||||
|
localConnection._setRemote(remoteConnection);
|
||||||
|
this._addConnection(peer, localConnection);
|
||||||
|
this.emit("connection", localConnection);
|
||||||
|
return localConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public call(
|
||||||
|
id: string,
|
||||||
|
stream: MediaStream,
|
||||||
|
options?: Peer.CallOption
|
||||||
|
): Peer.MediaConnection {
|
||||||
|
throw new Error("unimplemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconnect() {}
|
||||||
|
|
||||||
|
public getConnection(
|
||||||
|
peerId: string,
|
||||||
|
connectionId: string
|
||||||
|
): LocalDataConnection | null {
|
||||||
|
if (peerId in mocks) {
|
||||||
|
let map = mocks[peerId].connections;
|
||||||
|
if (connectionId in map) {
|
||||||
|
return map[connectionId][0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listAllPeers() {}
|
||||||
|
}
|
23
src/tests/unit/server.spec.ts
Normal file
23
src/tests/unit/server.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import LocalPeer from "@/testing/LocalPeer";
|
||||||
|
import Peer from "peerjs";
|
||||||
|
import { PeerServer, LocalClient, PeerClient } from "@/network";
|
||||||
|
|
||||||
|
describe("network/PeerServer", () => {
|
||||||
|
test("Create server and client", () => {
|
||||||
|
const serverPeer = new LocalPeer("test-server", {});
|
||||||
|
const clientPeer = new LocalPeer("test-client", {});
|
||||||
|
const serverPlayer = new LocalClient({
|
||||||
|
name: "server-client"
|
||||||
|
});
|
||||||
|
const roomInfo = {
|
||||||
|
max_players: 3,
|
||||||
|
password: ""
|
||||||
|
};
|
||||||
|
const server = new PeerServer(roomInfo, serverPlayer, serverPeer as Peer);
|
||||||
|
const client = new PeerClient(
|
||||||
|
"test-server",
|
||||||
|
{ name: "test" },
|
||||||
|
clientPeer as Peer
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3889,6 +3889,11 @@ eventemitter3@^3.0.0, eventemitter3@^3.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
||||||
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
||||||
|
|
||||||
|
eventemitter3@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
|
||||||
|
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
|
||||||
|
|
||||||
events@^3.0.0:
|
events@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
|
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
|
||||||
|
|
Loading…
Reference in a new issue