feat: case insensitive for non-ASCII text on main search (#3309)
* Added slug as dependecy and created a slugified String * add the slug and slugifyLikeString * using unsafeSql instead of the slug * need to fix the like on the watermelon side and need the slug anyway * watermelondb patch to change the like to use the upper or toUpperCase * Updated config.yml * Updated config.yml * implemented the sanitized fname and fix the discussion icon at search * add the search for non-latin alphabets * fix the searchRoom function * change the library of slug and added the unit tests * optional sanitizedFname * add some comment * remove @types/slug * remove watermelondb patch package * latin test, tweak at comment and tweak e2e test * minor tweak e2e * change typeText to replaceText at searchRoom * regexp to test the characters * add typeText on searchRoom * e2e search room replace and type * to fix the replace text for iOS and type non-ASCII on Android * minor tweak * minor tweak * enable artifact * disable artifacts * increase sleep time and change from toExist to toBeVisible * fix android flaky test
This commit is contained in:
parent
2d07b1682c
commit
61fe9dbb1e
|
@ -46,6 +46,7 @@ export interface ISubscription {
|
||||||
ls: Date;
|
ls: Date;
|
||||||
name: string;
|
name: string;
|
||||||
fname?: string;
|
fname?: string;
|
||||||
|
sanitizedFname?: string;
|
||||||
rid: string; // the same as id
|
rid: string; // the same as id
|
||||||
open: boolean;
|
open: boolean;
|
||||||
alert: boolean;
|
alert: boolean;
|
||||||
|
|
|
@ -29,6 +29,8 @@ export default class Subscription extends Model {
|
||||||
|
|
||||||
@field('fname') fname;
|
@field('fname') fname;
|
||||||
|
|
||||||
|
@field('sanitized_fname') sanitizedFname;
|
||||||
|
|
||||||
@field('rid') rid;
|
@field('rid') rid;
|
||||||
|
|
||||||
@field('open') open;
|
@field('open') open;
|
||||||
|
|
|
@ -266,6 +266,15 @@ export default schemaMigrations({
|
||||||
columns: [{ name: 'users_count', type: 'string', isOptional: true }]
|
columns: [{ name: 'users_count', type: 'string', isOptional: true }]
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toVersion: 22,
|
||||||
|
steps: [
|
||||||
|
addColumns({
|
||||||
|
table: 'subscriptions',
|
||||||
|
columns: [{ name: 'sanitized_fname', type: 'string', isOptional: true }]
|
||||||
|
})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||||
|
|
||||||
export default appSchema({
|
export default appSchema({
|
||||||
version: 21,
|
version: 22,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'subscriptions',
|
name: 'subscriptions',
|
||||||
|
@ -13,6 +13,7 @@ export default appSchema({
|
||||||
{ name: 'ls', type: 'number' },
|
{ name: 'ls', type: 'number' },
|
||||||
{ name: 'name', type: 'string', isIndexed: true },
|
{ name: 'name', type: 'string', isIndexed: true },
|
||||||
{ name: 'fname', type: 'string' },
|
{ name: 'fname', type: 'string' },
|
||||||
|
{ name: 'sanitized_fname', type: 'string', isOptional: true },
|
||||||
{ name: 'rid', type: 'string', isIndexed: true },
|
{ name: 'rid', type: 'string', isIndexed: true },
|
||||||
{ name: 'open', type: 'boolean' },
|
{ name: 'open', type: 'boolean' },
|
||||||
{ name: 'alert', type: 'boolean' },
|
{ name: 'alert', type: 'boolean' },
|
||||||
|
|
|
@ -39,3 +39,34 @@ describe('sanitizer', () => {
|
||||||
expect(utils.sanitizer(content)).toBe(content);
|
expect(utils.sanitizer(content)).toBe(content);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('slugifyLikeString', () => {
|
||||||
|
test('render empty', () => {
|
||||||
|
expect(utils.slugifyLikeString('')).toBe('');
|
||||||
|
expect(utils.slugifyLikeString(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
test('slugify the latin alphabet', () => {
|
||||||
|
expect(utils.slugifyLikeString('test123')).toBe('test123');
|
||||||
|
expect(utils.slugifyLikeString('TEST123')).toBe('test123');
|
||||||
|
});
|
||||||
|
test('slugify the russian alphabet', () => {
|
||||||
|
const textToSlugify = 'ПРОВЕРКА';
|
||||||
|
const textSlugified = 'proverka';
|
||||||
|
expect(utils.slugifyLikeString(textToSlugify)).toBe(textSlugified);
|
||||||
|
});
|
||||||
|
test('slugify the arabic alphabet', () => {
|
||||||
|
const textToSlugify = 'اختبار123';
|
||||||
|
const textSlugified = 'khtbr123';
|
||||||
|
expect(utils.slugifyLikeString(textToSlugify)).toBe(textSlugified);
|
||||||
|
});
|
||||||
|
test('slugify the chinese trad alphabet', () => {
|
||||||
|
const textToSlugify = '測試123';
|
||||||
|
const textSlugified = 'ce-shi-123';
|
||||||
|
expect(utils.slugifyLikeString(textToSlugify)).toBe(textSlugified);
|
||||||
|
});
|
||||||
|
test('slugify the japanese alphabet', () => {
|
||||||
|
const textToSlugify = 'テスト123';
|
||||||
|
const textSlugified = 'tesuto123';
|
||||||
|
expect(utils.slugifyLikeString(textToSlugify)).toBe(textSlugified);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,7 +1,19 @@
|
||||||
import XRegExp from 'xregexp';
|
import XRegExp from 'xregexp';
|
||||||
|
import { slugify } from 'transliteration';
|
||||||
|
|
||||||
// Matches letters from any alphabet and numbers
|
// Matches letters from any alphabet and numbers
|
||||||
const likeStringRegex = XRegExp('[^\\p{L}\\p{Nd}]', 'g');
|
const likeStringRegex = XRegExp('[^\\p{L}\\p{Nd}]', 'g');
|
||||||
export const sanitizeLikeString = (str?: string): string | undefined => str?.replace(likeStringRegex, '_');
|
export const sanitizeLikeString = (str?: string): string | undefined => str?.replace(likeStringRegex, '_');
|
||||||
|
|
||||||
|
// Will change any non-latin character to return a lower latin character string
|
||||||
|
// Example:
|
||||||
|
// slugifyLikeString('測試123') => 'ce-shi-123'
|
||||||
|
// slugifyLikeString('テスト123') => 'tesuto123'
|
||||||
|
export const slugifyLikeString = (str?: string) => {
|
||||||
|
if (!str) return '';
|
||||||
|
str?.replace(likeStringRegex, '_');
|
||||||
|
const slugified = slugify(str);
|
||||||
|
return slugified;
|
||||||
|
};
|
||||||
|
|
||||||
export const sanitizer = (r: object): object => r;
|
export const sanitizer = (r: object): object => r;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import EJSON from 'ejson';
|
import EJSON from 'ejson';
|
||||||
|
|
||||||
|
import { slugifyLikeString } from '../../database/utils';
|
||||||
import { Encryption } from '../../encryption';
|
import { Encryption } from '../../encryption';
|
||||||
import { store as reduxStore } from '../../store/auxStore';
|
import { store as reduxStore } from '../../store/auxStore';
|
||||||
import findSubscriptionsRooms from './findSubscriptionsRooms';
|
import findSubscriptionsRooms from './findSubscriptionsRooms';
|
||||||
|
@ -101,7 +102,7 @@ export const merge = (
|
||||||
mergedSubscription.blocker = !!mergedSubscription.blocker;
|
mergedSubscription.blocker = !!mergedSubscription.blocker;
|
||||||
mergedSubscription.blocked = !!mergedSubscription.blocked;
|
mergedSubscription.blocked = !!mergedSubscription.blocked;
|
||||||
mergedSubscription.hideMentionStatus = !!mergedSubscription.hideMentionStatus;
|
mergedSubscription.hideMentionStatus = !!mergedSubscription.hideMentionStatus;
|
||||||
|
mergedSubscription.sanitizedFname = slugifyLikeString(mergedSubscription.fname || mergedSubscription.name);
|
||||||
return mergedSubscription;
|
return mergedSubscription;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
|
|
||||||
import { sanitizeLikeString } from '../database/utils';
|
import { sanitizeLikeString, slugifyLikeString } from '../database/utils';
|
||||||
import database from '../database/index';
|
import database from '../database/index';
|
||||||
import { store as reduxStore } from '../store/auxStore';
|
import { store as reduxStore } from '../store/auxStore';
|
||||||
import { spotlight } from '../services/restApi';
|
import { spotlight } from '../services/restApi';
|
||||||
|
@ -15,10 +15,20 @@ export const localSearchSubscription = async ({ text = '', filterUsers = true, f
|
||||||
const searchText = text.trim();
|
const searchText = text.trim();
|
||||||
const db = database.active;
|
const db = database.active;
|
||||||
const likeString = sanitizeLikeString(searchText);
|
const likeString = sanitizeLikeString(searchText);
|
||||||
|
const slugifiedString = slugifyLikeString(searchText);
|
||||||
let subscriptions = await db
|
let subscriptions = await db
|
||||||
.get('subscriptions')
|
.get('subscriptions')
|
||||||
.query(
|
.query(
|
||||||
Q.or(Q.where('name', Q.like(`%${likeString}%`)), Q.where('fname', Q.like(`%${likeString}%`))),
|
Q.or(
|
||||||
|
// `sanitized_fname` is an optional column, so it's going to start null and it's going to get filled over time
|
||||||
|
Q.where('sanitized_fname', Q.like(`%${slugifiedString}%`)),
|
||||||
|
// TODO: Remove the conditionals below at some point. It is merged at 4.39
|
||||||
|
// the param 'name' is slugified by the server when the slugify setting is enable, just for channels and teams
|
||||||
|
Q.where('name', Q.like(`%${slugifiedString}%`)),
|
||||||
|
// Still need the below conditionals because at the first moment the the sanitized_fname won't be filled
|
||||||
|
Q.where('name', Q.like(`%${likeString}%`)),
|
||||||
|
Q.where('fname', Q.like(`%${likeString}%`))
|
||||||
|
),
|
||||||
Q.sortBy('room_updated_at', Q.desc)
|
Q.sortBy('room_updated_at', Q.desc)
|
||||||
)
|
)
|
||||||
.fetch();
|
.fetch();
|
||||||
|
@ -39,7 +49,8 @@ export const localSearchSubscription = async ({ text = '', filterUsers = true, f
|
||||||
encrypted: item.encrypted,
|
encrypted: item.encrypted,
|
||||||
lastMessage: item.lastMessage,
|
lastMessage: item.lastMessage,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
teamMain: item.teamMain
|
teamMain: item.teamMain,
|
||||||
|
prid: item.prid
|
||||||
})) as ISearchLocal[];
|
})) as ISearchLocal[];
|
||||||
|
|
||||||
return search;
|
return search;
|
||||||
|
|
|
@ -118,14 +118,18 @@ async function tapBack() {
|
||||||
await sleep(300); // Wait for animation to finish
|
await sleep(300); // Wait for animation to finish
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchRoom(room: string) {
|
async function searchRoom(room: string, roomTestID?: string) {
|
||||||
await waitFor(element(by.id('rooms-list-view')))
|
await waitFor(element(by.id('rooms-list-view')))
|
||||||
.toBeVisible()
|
.toBeVisible()
|
||||||
.withTimeout(30000);
|
.withTimeout(30000);
|
||||||
await tapAndWaitFor(element(by.id('rooms-list-view-search')), element(by.id('rooms-list-view-search-input')), 5000);
|
await tapAndWaitFor(element(by.id('rooms-list-view-search')), element(by.id('rooms-list-view-search-input')), 5000);
|
||||||
await element(by.id('rooms-list-view-search-input')).typeText(room);
|
// to fix the replace text for iOS and type non-ASCII on Android
|
||||||
await sleep(300);
|
const roomFirstSlice = room.slice(0, room.length - 2);
|
||||||
await waitFor(element(by.id(`rooms-list-view-item-${room}`)))
|
await element(by.id('rooms-list-view-search-input')).replaceText(roomFirstSlice);
|
||||||
|
await sleep(500);
|
||||||
|
await element(by.id('rooms-list-view-search-input')).replaceText(room);
|
||||||
|
await sleep(500);
|
||||||
|
await waitFor(element(by.id(roomTestID || `rooms-list-view-item-${room}`)))
|
||||||
.toBeVisible()
|
.toBeVisible()
|
||||||
.withTimeout(60000);
|
.withTimeout(60000);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { device, waitFor, element, by, expect } from 'detox';
|
import { device, waitFor, element, by, expect } from 'detox';
|
||||||
|
|
||||||
import { tapBack, navigateToLogin, login, platformTypes, TTextMatcher, tapAndWaitFor } from '../../helpers/app';
|
import { tapBack, navigateToLogin, login, platformTypes, TTextMatcher, tapAndWaitFor, searchRoom } from '../../helpers/app';
|
||||||
import { createRandomUser } from '../../helpers/data_setup';
|
import { createRandomUser } from '../../helpers/data_setup';
|
||||||
import random from '../../helpers/random';
|
import random from '../../helpers/random';
|
||||||
|
|
||||||
|
@ -238,6 +238,9 @@ describe('Create room screen', () => {
|
||||||
await waitFor(element(by.id('create-channel-view')))
|
await waitFor(element(by.id('create-channel-view')))
|
||||||
.toExist()
|
.toExist()
|
||||||
.withTimeout(10000);
|
.withTimeout(10000);
|
||||||
|
await waitFor(element(by.id('create-channel-name')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(2000);
|
||||||
await element(by.id('create-channel-name')).replaceText(room);
|
await element(by.id('create-channel-name')).replaceText(room);
|
||||||
await element(by.id('create-channel-name')).tapReturnKey();
|
await element(by.id('create-channel-name')).tapReturnKey();
|
||||||
await waitFor(element(by.id('create-channel-submit')))
|
await waitFor(element(by.id('create-channel-submit')))
|
||||||
|
@ -261,6 +264,61 @@ describe('Create room screen', () => {
|
||||||
.withTimeout(60000);
|
.withTimeout(60000);
|
||||||
await expect(element(by.id(`rooms-list-view-item-${room}`))).toExist();
|
await expect(element(by.id(`rooms-list-view-item-${room}`))).toExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create a room with non-latin alphabet and do a case insensitive search for it', async () => {
|
||||||
|
const randomValue = random();
|
||||||
|
const roomName = `ПРОВЕРКА${randomValue}`;
|
||||||
|
const roomNameLower = roomName.toLowerCase();
|
||||||
|
|
||||||
|
await waitFor(element(by.id('rooms-list-view')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(10000);
|
||||||
|
await element(by.id('rooms-list-view-create-channel')).tap();
|
||||||
|
await waitFor(element(by.id('new-message-view')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
await waitFor(element(by.id('new-message-view-create-channel')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(2000);
|
||||||
|
await element(by.id('new-message-view-create-channel')).tap();
|
||||||
|
await waitFor(element(by.id('select-users-view')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(5000);
|
||||||
|
await element(by.id('selected-users-view-submit')).tap();
|
||||||
|
await waitFor(element(by.id('create-channel-view')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(10000);
|
||||||
|
await waitFor(element(by.id('create-channel-name')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(2000);
|
||||||
|
await element(by.id('create-channel-name')).replaceText(roomName);
|
||||||
|
await element(by.id('create-channel-name')).tapReturnKey();
|
||||||
|
await waitFor(element(by.id('create-channel-submit')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(2000);
|
||||||
|
await element(by.id('create-channel-submit')).tap();
|
||||||
|
await waitFor(element(by.id('room-view')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(60000);
|
||||||
|
await expect(element(by.id('room-view'))).toExist();
|
||||||
|
await waitFor(element(by.id(`room-view-title-${roomName}`)))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(60000);
|
||||||
|
await expect(element(by.id(`room-view-title-${roomName}`))).toExist();
|
||||||
|
await tapBack();
|
||||||
|
await waitFor(element(by.id('rooms-list-view')))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(2000);
|
||||||
|
await waitFor(element(by.id(`rooms-list-view-item-${roomName}`)))
|
||||||
|
.toExist()
|
||||||
|
.withTimeout(60000);
|
||||||
|
await expect(element(by.id(`rooms-list-view-item-${roomName}`))).toExist();
|
||||||
|
await searchRoom(roomNameLower, `rooms-list-view-item-${roomName}`);
|
||||||
|
await element(by.id(`rooms-list-view-item-${roomName}`)).tap();
|
||||||
|
await waitFor(element(by.id('room-view')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -490,11 +490,11 @@ describe('Room screen', () => {
|
||||||
const { name: replyRoom } = await createRandomRoom(replyUser, 'c');
|
const { name: replyRoom } = await createRandomRoom(replyUser, 'c');
|
||||||
const originalMessage = 'Message to reply in DM';
|
const originalMessage = 'Message to reply in DM';
|
||||||
const replyMessage = 'replied in dm';
|
const replyMessage = 'replied in dm';
|
||||||
|
await sendMessage(replyUser, replyRoom, originalMessage);
|
||||||
await waitFor(element(by.id('rooms-list-view')))
|
await waitFor(element(by.id('rooms-list-view')))
|
||||||
.toBeVisible()
|
.toBeVisible()
|
||||||
.withTimeout(2000);
|
.withTimeout(2000);
|
||||||
await navigateToRoom(replyRoom);
|
await navigateToRoom(replyRoom);
|
||||||
await sendMessage(replyUser, replyRoom, originalMessage);
|
|
||||||
await waitFor(element(by[textMatcher](originalMessage)).atIndex(0))
|
await waitFor(element(by[textMatcher](originalMessage)).atIndex(0))
|
||||||
.toBeVisible()
|
.toBeVisible()
|
||||||
.withTimeout(10000);
|
.withTimeout(10000);
|
||||||
|
@ -502,16 +502,27 @@ describe('Room screen', () => {
|
||||||
await waitFor(element(by.id('room-view-join-button')))
|
await waitFor(element(by.id('room-view-join-button')))
|
||||||
.not.toBeVisible()
|
.not.toBeVisible()
|
||||||
.withTimeout(10000);
|
.withTimeout(10000);
|
||||||
await element(by[textMatcher](originalMessage)).atIndex(0).tap();
|
|
||||||
await element(by[textMatcher](originalMessage)).atIndex(0).longPress();
|
await element(by[textMatcher](originalMessage)).atIndex(0).longPress();
|
||||||
await sleep(300); // wait for animation
|
await sleep(600); // wait for animation
|
||||||
await waitFor(element(by.id('action-sheet')))
|
await waitFor(element(by.id('action-sheet')))
|
||||||
.toExist()
|
.toExist()
|
||||||
.withTimeout(2000);
|
.withTimeout(2000);
|
||||||
await waitFor(element(by[textMatcher]('Reply in Direct Message')).atIndex(0))
|
await sleep(600); // wait for animation
|
||||||
|
// Fix android flaky test. Close the action sheet, then re-open again
|
||||||
|
await element(by.id('action-sheet-handle')).swipe('down', 'fast', 0.5);
|
||||||
|
await sleep(1000); // wait for animation
|
||||||
|
await element(by[textMatcher](originalMessage)).atIndex(0).longPress();
|
||||||
|
await sleep(600); // wait for animation
|
||||||
|
await waitFor(element(by.id('action-sheet')))
|
||||||
.toExist()
|
.toExist()
|
||||||
|
.withTimeout(2000);
|
||||||
|
await sleep(600); // wait for animation
|
||||||
|
await waitFor(element(by[textMatcher]('Reply in Direct Message')).atIndex(0))
|
||||||
|
.toBeVisible()
|
||||||
.withTimeout(6000);
|
.withTimeout(6000);
|
||||||
|
await sleep(600); // wait for animation
|
||||||
await element(by[textMatcher]('Reply in Direct Message')).atIndex(0).tap();
|
await element(by[textMatcher]('Reply in Direct Message')).atIndex(0).tap();
|
||||||
|
await sleep(600); // wait for animation
|
||||||
await waitFor(element(by.id(`room-view-title-${replyUser.username}`)))
|
await waitFor(element(by.id(`room-view-title-${replyUser.username}`)))
|
||||||
.toExist()
|
.toExist()
|
||||||
.withTimeout(6000);
|
.withTimeout(6000);
|
||||||
|
|
|
@ -143,6 +143,7 @@
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"rn-root-view": "RocketChat/rn-root-view",
|
"rn-root-view": "RocketChat/rn-root-view",
|
||||||
"semver": "^7.3.8",
|
"semver": "^7.3.8",
|
||||||
|
"transliteration": "^2.3.5",
|
||||||
"ua-parser-js": "^1.0.32",
|
"ua-parser-js": "^1.0.32",
|
||||||
"uri-js": "^4.4.1",
|
"uri-js": "^4.4.1",
|
||||||
"url-parse": "1.5.10",
|
"url-parse": "1.5.10",
|
||||||
|
|
|
@ -19640,6 +19640,13 @@ transformation-matrix@^2.8.0:
|
||||||
resolved "https://registry.yarnpkg.com/transformation-matrix/-/transformation-matrix-2.12.0.tgz#cb826a23aa5d675d18940215ccb7613b8587830f"
|
resolved "https://registry.yarnpkg.com/transformation-matrix/-/transformation-matrix-2.12.0.tgz#cb826a23aa5d675d18940215ccb7613b8587830f"
|
||||||
integrity sha512-BbzXM7el7rNwIr1s87m8tcffH5qgY+HYROLn3BStRU9Y6vYTL37YZKadfNPEvGbP813iA1h8qflo4pa2TomkyQ==
|
integrity sha512-BbzXM7el7rNwIr1s87m8tcffH5qgY+HYROLn3BStRU9Y6vYTL37YZKadfNPEvGbP813iA1h8qflo4pa2TomkyQ==
|
||||||
|
|
||||||
|
transliteration@^2.3.5:
|
||||||
|
version "2.3.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
|
||||||
|
integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
|
||||||
|
dependencies:
|
||||||
|
yargs "^17.5.1"
|
||||||
|
|
||||||
traverse@~0.6.6:
|
traverse@~0.6.6:
|
||||||
version "0.6.6"
|
version "0.6.6"
|
||||||
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"
|
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"
|
||||||
|
|
Loading…
Reference in New Issue