feat: add desktop repo (#1071)

This commit is contained in:
Mo
2022-06-07 11:52:15 -05:00
committed by GitHub
parent 0bb12db948
commit 0b7ce82aaa
135 changed files with 17821 additions and 180 deletions

View File

@@ -0,0 +1,52 @@
export interface TestIPCMessage {
id: number
type: MessageType
args: any[]
}
export interface TestIPCMessageResult {
id: number
resolve?: any
reject?: any
}
export interface AppTestMessage {
type: AppMessageType
}
export enum AppMessageType {
Ready,
WindowLoaded,
SavedBackup,
Log,
}
export enum MessageType {
WindowCount,
StoreData,
StoreSettingsLocation,
StoreSet,
SetLocalStorageValue,
AppMenuItems,
SpellCheckerManager,
SpellCheckerLanguages,
ClickLanguage,
BackupsAreEnabled,
ToggleBackupsEnabled,
BackupsLocation,
PerformBackup,
ChangeBackupsLocation,
CopyDecryptScript,
MenuReloaded,
UpdateState,
CheckForUpdate,
UpdateManagerNotifiedStateChange,
Relaunch,
DataArchive,
GetJSON,
DownloadFile,
AutoUpdateEnabled,
HasReloadedMenu,
AppStateCall,
SignOut,
}

View File

@@ -0,0 +1,123 @@
import { promises as fs } from 'fs'
import path from 'path'
import anyTest, { TestFn } from 'ava'
import { Driver, createDriver } from './driver'
const test = anyTest as TestFn<Driver>
const BackupsDirectoryName = 'Standard Notes Backups'
test.beforeEach(async (t) => {
t.context = await createDriver()
const backupsLocation = await t.context.backups.location()
await fs.rmdir(backupsLocation, { recursive: true })
await t.context.backups.copyDecryptScript(backupsLocation)
})
test.afterEach.always(async (t) => {
await t.context.stop()
})
/**
* Depending on the current system load, performing a backup
* might take a while
*/
const timeoutDuration = 20 * 1000 /** 20s */
function wait(duration = 1000) {
return new Promise((resolve) => setTimeout(resolve, duration))
}
test('saves incoming data to the backups folder', async (t) => {
const data = 'Sample Data'
const fileName = await t.context.backups.save(data)
const backupsLocation = await t.context.backups.location()
const files = await fs.readdir(backupsLocation)
t.true(files.includes(fileName))
t.is(data, await fs.readFile(path.join(backupsLocation, fileName), 'utf8'))
})
test('saves the decrypt script to the backups folder', async (t) => {
const backupsLocation = await t.context.backups.location()
await wait(300) /** Disk might be busy */
const files = await fs.readdir(backupsLocation)
t.true(files.includes('decrypt.html'))
})
test('performs a backup', async (t) => {
t.timeout(timeoutDuration)
await wait()
await t.context.backups.perform()
const backupsLocation = await t.context.backups.location()
const files = await fs.readdir(backupsLocation)
t.true(files.length >= 1)
})
test('changes backups folder location', async (t) => {
t.timeout(timeoutDuration)
await wait()
await t.context.backups.perform()
let newLocation = path.join(t.context.userDataPath, 'newLocation')
await fs.mkdir(newLocation)
const currentLocation = await t.context.backups.location()
const fileNames = await fs.readdir(currentLocation)
await t.context.backups.changeLocation(newLocation)
newLocation = path.join(newLocation, BackupsDirectoryName)
t.deepEqual(fileNames, await fs.readdir(newLocation))
/** Assert that the setting was saved */
const data = await t.context.storage.dataOnDisk()
t.is(data.backupsLocation, newLocation)
/** Perform backup and make sure there is one more file in the directory */
await t.context.backups.perform()
const newFileNames = await fs.readdir(newLocation)
t.deepEqual(newFileNames.length, fileNames.length + 1)
})
test('changes backups location to a child directory', async (t) => {
t.timeout(timeoutDuration)
await wait()
await t.context.backups.perform()
const currentLocation = await t.context.backups.location()
const backups = await fs.readdir(currentLocation)
t.is(backups.length, 2) /** 1 + decrypt script */
const newLocation = path.join(currentLocation, 'child_dir')
await t.context.backups.changeLocation(newLocation)
t.deepEqual(await fs.readdir(path.join(newLocation, BackupsDirectoryName)), backups)
})
test('changing backups location to the same directory should not do anything', async (t) => {
t.timeout(timeoutDuration)
await wait()
await t.context.backups.perform()
await t.context.backups.perform()
const currentLocation = await t.context.backups.location()
let totalFiles = (await fs.readdir(currentLocation)).length
t.is(totalFiles, 3) /** 2 + decrypt script */
await t.context.backups.changeLocation(currentLocation)
totalFiles = (await fs.readdir(currentLocation)).length
t.is(totalFiles, 3)
})
test('backups are enabled by default', async (t) => {
t.is(await t.context.backups.enabled(), true)
})
test('does not save a backup when they are disabled', async (t) => {
await t.context.backups.toggleEnabled()
await t.context.windowLoaded
/** Do not wait on this one as the backup shouldn't be triggered */
t.context.backups.perform()
await wait()
const backupsLocation = await t.context.backups.location()
const files = await fs.readdir(backupsLocation)
t.deepEqual(files, ['decrypt.html'])
})

Binary file not shown.

View File

@@ -0,0 +1,203 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ChildProcess, spawn } from 'child_process'
import electronPath, { MenuItem } from 'electron'
import path from 'path'
import { deleteDir, ensureDirectoryExists, readJSONFile } from '../app/javascripts/Main/Utils/fileUtils'
import { Language } from '../app/javascripts/Main/spellcheckerManager'
import { StoreKeys } from '../app/javascripts/Main/store'
import { UpdateState } from '../app/javascripts/Main/updateManager'
import { CommandLineArgs } from '../app/javascripts/Shared/CommandLineArgs'
import { AppMessageType, AppTestMessage, MessageType, TestIPCMessage, TestIPCMessageResult } from './TestIpcMessage'
function spawnAppprocess(userDataPath: string) {
const p = spawn(
electronPath as any,
[path.join(__dirname, '..'), CommandLineArgs.Testing, CommandLineArgs.UserDataPath, userDataPath],
{
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
},
)
return p
}
class Driver {
private appProcess: ChildProcess
private calls: Array<{
resolve: (...args: any) => void
reject: (...args: any) => void
} | null> = []
private awaitedOnMessages: Array<{
type: AppMessageType
resolve: (...args: any) => void
}> = []
appReady: Promise<unknown>
windowLoaded: Promise<unknown>
constructor(readonly userDataPath: string) {
this.appProcess = spawnAppprocess(userDataPath)
this.appProcess.on('message', this.receive)
this.appReady = this.waitOn(AppMessageType.Ready)
this.windowLoaded = this.waitOn(AppMessageType.WindowLoaded)
}
private receive = (message: TestIPCMessageResult | AppTestMessage) => {
if ('type' in message) {
if (message.type === AppMessageType.Log) {
console.log(message)
}
this.awaitedOnMessages = this.awaitedOnMessages.filter(({ type, resolve }) => {
if (type === message.type) {
resolve()
return false
}
return true
})
}
if ('id' in message) {
const call = this.calls[message.id]!
this.calls[message.id] = null
if (message.reject) {
call.reject(message.reject)
} else {
call.resolve(message.resolve)
}
}
}
private waitOn = (messageType: AppMessageType) => {
return new Promise((resolve) => {
this.awaitedOnMessages.push({
type: messageType,
resolve,
})
})
}
private send = (type: MessageType, ...args: any): Promise<any> => {
const id = this.calls.length
const message: TestIPCMessage = {
id,
type,
args,
}
this.appProcess.send(message)
return new Promise((resolve, reject) => {
this.calls.push({ resolve, reject })
})
}
windowCount = (): Promise<number> => this.send(MessageType.WindowCount)
appStateCall = (methodName: string, ...args: any): Promise<any> =>
this.send(MessageType.AppStateCall, methodName, ...args)
readonly window = {
signOut: (): Promise<void> => this.send(MessageType.SignOut),
}
readonly storage = {
dataOnDisk: async (): Promise<{ [key in StoreKeys]: any }> => {
const location = await this.send(MessageType.StoreSettingsLocation)
return readJSONFile(location)
},
dataLocation: (): Promise<string> => this.send(MessageType.StoreSettingsLocation),
setZoomFactor: (factor: number) => this.send(MessageType.StoreSet, 'zoomFactor', factor),
setLocalStorageValue: (key: string, value: string): Promise<void> =>
this.send(MessageType.SetLocalStorageValue, key, value),
}
readonly appMenu = {
items: (): Promise<MenuItem[]> => this.send(MessageType.AppMenuItems),
clickLanguage: (language: Language) => this.send(MessageType.ClickLanguage, language),
hasReloaded: () => this.send(MessageType.HasReloadedMenu),
}
readonly spellchecker = {
manager: () => this.send(MessageType.SpellCheckerManager),
languages: () => this.send(MessageType.SpellCheckerLanguages),
}
readonly backups = {
enabled: (): Promise<boolean> => this.send(MessageType.BackupsAreEnabled),
toggleEnabled: (): Promise<boolean> => this.send(MessageType.ToggleBackupsEnabled),
location: (): Promise<string> => this.send(MessageType.BackupsLocation),
copyDecryptScript: async (location: string) => {
await this.send(MessageType.CopyDecryptScript, location)
},
changeLocation: (location: string) => this.send(MessageType.ChangeBackupsLocation, location),
save: (data: any) => this.send(MessageType.DataArchive, data),
perform: async () => {
await this.windowLoaded
await this.send(MessageType.PerformBackup)
await this.waitOn(AppMessageType.SavedBackup)
},
}
readonly updates = {
state: (): Promise<UpdateState> => this.send(MessageType.UpdateState),
autoUpdateEnabled: (): Promise<boolean> => this.send(MessageType.AutoUpdateEnabled),
check: () => this.send(MessageType.CheckForUpdate),
}
readonly net = {
getJSON: (url: string) => this.send(MessageType.GetJSON, url),
downloadFile: (url: string, filePath: string) => this.send(MessageType.DownloadFile, url, filePath),
}
stop = async () => {
this.appProcess.kill()
/** Give the process a little time before cleaning up */
await new Promise((resolve) => setTimeout(resolve, 150))
/**
* Windows can throw EPERM or EBUSY errors when we try to delete the
* user data directory too quickly.
*/
const maxTries = 5
for (let i = 0; i < maxTries; i++) {
try {
await deleteDir(this.userDataPath)
return
} catch (error: any) {
if (error.code === 'EPERM' || error.code === 'EBUSY') {
await new Promise((resolve) => setTimeout(resolve, 300))
} else {
throw error
}
}
}
throw new Error(`Couldn't delete user data directory after ${maxTries} tries`)
}
restart = async () => {
this.appProcess.kill()
this.appProcess = spawnAppprocess(this.userDataPath)
this.appProcess.on('message', this.receive)
this.appReady = this.waitOn(AppMessageType.Ready)
this.windowLoaded = this.waitOn(AppMessageType.WindowLoaded)
await this.appReady
}
}
export type { Driver }
export async function createDriver() {
const userDataPath = path.join(
__dirname,
'data',
'tmp',
`userData-${Date.now()}-${Math.round(Math.random() * 10000)}`,
)
await ensureDirectoryExists(userDataPath)
const driver = new Driver(userDataPath)
await driver.appReady
return driver
}

View File

@@ -0,0 +1,130 @@
import anyTest, { TestFn } from 'ava'
import { promises as fs } from 'fs'
import http from 'http'
import { AddressInfo } from 'net'
import path from 'path'
import proxyquire from 'proxyquire'
import { ensureDirectoryExists } from '../app/javascripts/Main/Utils/FileUtils'
import { initializeStrings } from '../app/javascripts/Main/strings'
import { createTmpDir } from './testUtils'
import makeFakePaths from './fakePaths'
const test = anyTest as TestFn<{
server: http.Server
host: string
}>
const tmpDir = createTmpDir(__filename)
const FakePaths = makeFakePaths(tmpDir.path)
let server: http.Server
const { createExtensionsServer, normalizeFilePath } = proxyquire('../app/javascripts/Main/ExtensionsServer', {
'./paths': {
Paths: FakePaths,
'@noCallThru': true,
},
electron: {
app: {
getPath() {
return tmpDir.path
},
},
},
http: {
createServer(...args: any) {
server = http.createServer(...args)
return server
},
},
})
const extensionsDir = path.join(tmpDir.path, 'Extensions')
initializeStrings('en')
const log = console.log
const error = console.error
test.before(async (t) => {
/** Prevent the extensions server from outputting anything */
// eslint-disable-next-line @typescript-eslint/no-empty-function
// console.log = () => {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
// console.error = () => {}
await ensureDirectoryExists(extensionsDir)
await new Promise((resolve) => {
createExtensionsServer(resolve)
t.context.server = server
server.once('listening', () => {
const { address, port } = server.address() as AddressInfo
t.context.host = `http://${address}:${port}/`
resolve(null)
})
})
})
test.after((t): Promise<any> => {
/** Restore the console's functionality */
console.log = log
console.error = error
return Promise.all([tmpDir.clean(), new Promise((resolve) => t.context.server.close(resolve))])
})
test('serves the files in the Extensions directory over HTTP', (t) => {
const data = {
name: 'Boxes',
meter: {
4: 4,
},
syncopation: true,
instruments: ['Drums', 'Bass', 'Vocals', { name: 'Piano', type: 'Electric' }],
}
return fs.writeFile(path.join(extensionsDir, 'file.json'), JSON.stringify(data)).then(
() =>
new Promise((resolve) => {
let serverData = ''
http.get(t.context.host + 'Extensions/file.json').on('response', (response) => {
response
.setEncoding('utf-8')
.on('data', (chunk) => {
serverData += chunk
})
.on('end', () => {
t.deepEqual(data, JSON.parse(serverData))
resolve()
})
})
}),
)
})
test('does not serve files outside the Extensions directory', async (t) => {
await new Promise((resolve) => {
http.get(t.context.host + 'Extensions/../../../package.json').on('response', (response) => {
t.is(response.statusCode, 500)
resolve(true)
})
})
})
test('returns a 404 for files that are not present', async (t) => {
await new Promise((resolve) => {
http.get(t.context.host + 'Extensions/nothing').on('response', (response) => {
t.is(response.statusCode, 404)
resolve(true)
})
})
})
test('normalizes file paths to always point somewhere in the Extensions directory', (t) => {
t.is(normalizeFilePath('/Extensions/test/yes', '127.0.0.1'), path.join(tmpDir.path, 'Extensions', 'test', 'yes'))
t.is(
normalizeFilePath('/Extensions/../../data/outside/the/extensions/directory'),
path.join(tmpDir.path, 'Extensions', 'data', 'outside', 'the', 'extensions', 'directory'),
)
})

View File

@@ -0,0 +1,29 @@
import path from 'path'
export default function makeFakePaths(tmpDir: string) {
const Paths = {
get userDataDir(): string {
return tmpDir
},
get documentsDir(): string {
return tmpDir
},
get tempDir(): string {
return tmpDir
},
get extensionsDirRelative(): string {
return 'Extensions'
},
get extensionsDir(): string {
return path.join(Paths.userDataDir, 'Extensions')
},
get extensionsMappingJson(): string {
return path.join(Paths.extensionsDir, 'mapping.json')
},
get windowPositionJson(): string {
return path.join(Paths.userDataDir, 'window-position.json')
},
}
return Paths
}

View File

@@ -0,0 +1,111 @@
import test, { TestFn } from 'ava'
import { promises as fs } from 'fs'
import path from 'path'
import {
deleteDir,
ensureDirectoryExists,
extractNestedZip,
FileDoesNotExist,
moveDirContents,
readJSONFile,
writeJSONFile,
} from '../app/javascripts/Main/Utils/FileUtils'
const dataPath = path.join(__dirname, 'data')
const tmpPath = path.join(dataPath, 'tmp', path.basename(__filename))
const zipFileDestination = path.join(tmpPath, 'zip-file-output')
const root = path.join(tmpPath, 'tmp1')
test.beforeEach(async () => {
await ensureDirectoryExists(root)
})
test.afterEach(async () => {
await deleteDir(tmpPath)
})
test('extracts a zip and unnests the folders by one level', async (t) => {
await extractNestedZip(path.join(dataPath, 'zip-file.zip'), zipFileDestination)
t.deepEqual(await fs.readdir(zipFileDestination), ['package.json', 'test-file.txt'])
})
test('creates a directory even when parent directories are non-existent', async (t) => {
await ensureDirectoryExists(path.join(root, 'tmp2', 'tmp3'))
t.deepEqual(await fs.readdir(root), ['tmp2'])
t.deepEqual(await fs.readdir(path.join(root, 'tmp2')), ['tmp3'])
})
test('deletes a deeply-nesting directory', async (t) => {
await ensureDirectoryExists(path.join(root, 'tmp2', 'tmp3'))
await deleteDir(root)
try {
await fs.readdir(path.join(tmpPath, 'tmp1'))
t.fail('Should not have been able to read')
} catch (error: any) {
if (error.code === FileDoesNotExist) {
t.pass()
} else {
t.fail(error)
}
}
})
test('moves the contents of one directory to the other', async (t) => {
const fileNames = ['1.txt', '2.txt', '3.txt', 'nested/4.txt', 'nested/5.txt', 'nested/6.txt']
/** Create a temp directory and fill it with files */
const dir = path.join(tmpPath, 'move_contents_src')
await ensureDirectoryExists(dir)
await ensureDirectoryExists(path.join(dir, 'nested'))
await Promise.all(fileNames.map((fileName) => fs.writeFile(path.join(dir, fileName), fileName)))
/** Now move its contents */
const dest = path.join(tmpPath, 'move_contents_dest')
await moveDirContents(dir, dest)
await Promise.all(
fileNames.map(async (fileName) => {
const contents = await fs.readFile(path.join(dest, fileName), 'utf8')
t.is(contents, fileName)
}),
)
})
test('moves the contents of one directory to a child directory', async (t) => {
const srcFileNames = ['1.txt', '2.txt', '3.txt', 'nested/4.txt', 'nested/5.txt', 'nested/6.txt']
const destFileNames = ['1.txt', '2.txt', '3.txt', '4.txt', '5.txt', '6.txt']
/** Create a temp directory and fill it with files */
const dir = path.join(tmpPath, 'move_contents_src')
await ensureDirectoryExists(dir)
await ensureDirectoryExists(path.join(dir, 'nested'))
await Promise.all(srcFileNames.map((fileName) => fs.writeFile(path.join(dir, fileName), fileName)))
/** Now move its contents */
const dest = path.join(dir, 'nested')
await moveDirContents(dir, dest)
/** Ensure everything is there */
t.deepEqual((await fs.readdir(dest)).sort(), destFileNames.sort())
await Promise.all(
destFileNames.map(async (fileName, index) => {
const contents = await fs.readFile(path.join(dest, fileName), 'utf8')
t.is(contents, srcFileNames[index])
}),
)
})
test('serializes and deserializes an object to the same values', async (t) => {
const data = {
meter: {
4: 4,
},
chorus: {
passengers: 2,
destination: 'moon',
activities: [{ type: 'play', environment: 'stars' }],
},
}
const filePath = path.join(tmpPath, 'data.json')
await writeJSONFile(filePath, data)
t.deepEqual(data, await readJSONFile(filePath))
})

View File

@@ -0,0 +1,49 @@
import anyTest, { TestFn } from 'ava'
import { MenuItem } from 'electron'
import { AppName } from '../app/javascripts/Main/strings'
import { createDriver, Driver } from './driver'
const test = anyTest as TestFn<{
driver: Driver
menuItems: MenuItem[]
}>
test.before(async (t) => {
t.context.driver = await createDriver()
})
test.after.always(async (t) => {
await t.context.driver.stop()
})
test.beforeEach(async (t) => {
t.context.menuItems = await t.context.driver.appMenu.items()
})
function findSpellCheckerLanguagesMenu(menuItems: MenuItem[]) {
return menuItems.find((item) => {
if (item.role?.toLowerCase() === 'editmenu') {
return item?.submenu?.items?.find((item) => item.id === 'SpellcheckerLanguages')
}
})
}
if (process.platform === 'darwin') {
test('shows the App menu on Mac', (t) => {
t.is(t.context.menuItems[0].role.toLowerCase(), 'appmenu')
t.is(t.context.menuItems[0].label, AppName)
})
test('hides the spellchecking submenu on Mac', (t) => {
t.falsy(findSpellCheckerLanguagesMenu(t.context.menuItems))
})
} else {
test('hides the App menu on Windows/Linux', (t) => {
t.is(t.context.menuItems[0].role.toLowerCase(), 'editmenu')
})
test('shows the spellchecking submenu on Windows/Linux', (t) => {
const menu = findSpellCheckerLanguagesMenu(t.context.menuItems)
t.truthy(menu)
t.true(menu!.submenu!.items!.length > 0)
})
}

View File

@@ -0,0 +1,58 @@
import anyTest, { TestFn } from 'ava'
import { promises as fs } from 'fs'
import http from 'http'
import { AddressInfo } from 'net'
import path from 'path'
import { createDriver, Driver } from './driver'
import { createTmpDir } from './testUtils'
const test = anyTest as TestFn<Driver>
const tmpDir = createTmpDir(__filename)
const sampleData = {
title: 'Diamond Dove',
meter: {
4: 4,
},
instruments: ['Piano', 'Chiptune'],
}
let server: http.Server
let serverAddress: string
test.before(
(): Promise<any> =>
Promise.all([
tmpDir.make(),
new Promise((resolve) => {
server = http.createServer((_req, res) => {
res.write(JSON.stringify(sampleData))
res.end()
})
server.listen(0, '127.0.0.1', () => {
const { address, port } = server.address() as AddressInfo
serverAddress = `http://${address}:${port}`
resolve(null)
})
}),
]),
)
test.after((): Promise<any> => Promise.all([tmpDir.clean(), new Promise((resolve) => server.close(resolve))]))
test.beforeEach(async (t) => {
t.context = await createDriver()
})
test.afterEach((t) => t.context.stop())
test('downloads a JSON file', async (t) => {
t.deepEqual(await t.context.net.getJSON(serverAddress), sampleData)
})
test('downloads a folder to the specified location', async (t) => {
const filePath = path.join(tmpDir.path, 'fileName.json')
await t.context.net.downloadFile(serverAddress + '/file', filePath)
const fileContents = await fs.readFile(filePath, 'utf8')
t.is(JSON.stringify(sampleData), fileContents)
})

View File

@@ -0,0 +1,158 @@
import test from 'ava'
import { promises as fs } from 'fs'
import path from 'path'
import proxyquire from 'proxyquire'
import { ensureDirectoryExists, readJSONFile } from '../app/javascripts/Main/Utils/FileUtils'
import { createTmpDir } from './testUtils'
import { AppName } from '../app/javascripts/Main/strings'
import makeFakePaths from './fakePaths'
import { PackageManagerInterface } from '../app/javascripts/Main/Packages/PackageManagerInterface'
const tmpDir = createTmpDir(__filename)
const FakePaths = makeFakePaths(tmpDir.path)
const contentDir = path.join(tmpDir.path, 'Extensions')
let downloadFileCallCount = 0
const { initializePackageManager } = proxyquire('../app/javascripts/Main/Packages/PackageManager', {
'./paths': {
Paths: FakePaths,
'@noCallThru': true,
},
'./networking': {
/** Download a fake component file */
async downloadFile(_src: string, dest: string) {
downloadFileCallCount += 1
if (!path.normalize(dest).startsWith(tmpDir.path)) {
throw new Error(`Bad download destination: ${dest}`)
}
await ensureDirectoryExists(path.dirname(dest))
await fs.copyFile(path.join(__dirname, 'data', 'zip-file.zip'), path.join(dest))
},
},
})
const fakeWebContents = {
send(_eventName: string, { error }) {
if (error) throw error
},
}
const name = 'Fake Component'
const identifier = 'fake.component'
const uuid = 'fake-component'
const version = '1.0.0'
const modifiers = Array(20)
.fill(0)
.map((_, i) => String(i).padStart(2, '0'))
function fakeComponent({ deleted = false, modifier = '' } = {}) {
return {
uuid: uuid + modifier,
deleted,
content: {
name: name + modifier,
autoupdateDisabled: false,
package_info: {
version,
identifier: identifier + modifier,
download_url: 'https://standardnotes.com',
url: 'https://standardnotes.com',
latest_url: 'https://standardnotes.com',
},
},
}
}
let packageManager: PackageManagerInterface
const log = console.log
const error = console.error
test.before(async function () {
/** Silence the package manager's output. */
// eslint-disable-next-line @typescript-eslint/no-empty-function
console.log = () => {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
console.error = () => {}
await ensureDirectoryExists(contentDir)
packageManager = await initializePackageManager(fakeWebContents)
})
test.after.always(async function () {
console.log = log
console.error = error
await tmpDir.clean()
})
test.beforeEach(function () {
downloadFileCallCount = 0
})
test('installs multiple components', async (t) => {
await packageManager.syncComponents(modifiers.map((modifier) => fakeComponent({ modifier })))
await new Promise((resolve) => setTimeout(resolve, 200))
const files = await fs.readdir(contentDir)
t.is(files.length, 1 + modifiers.length)
for (const modifier of modifiers) {
t.true(files.includes(identifier + modifier))
}
t.true(files.includes('mapping.json'))
const mappingContents = await fs.readFile(path.join(contentDir, 'mapping.json'), 'utf8')
t.deepEqual(
JSON.parse(mappingContents),
modifiers.reduce((acc, modifier) => {
acc[uuid + modifier] = {
location: path.join('Extensions', identifier + modifier),
version,
}
return acc
}, {}),
)
const downloads = await fs.readdir(path.join(tmpDir.path, AppName, 'downloads'))
t.is(downloads.length, modifiers.length)
for (const modifier of modifiers) {
t.true(downloads.includes(`${name + modifier}.zip`))
}
for (const modifier of modifiers) {
const componentFiles = await fs.readdir(path.join(contentDir, identifier + modifier))
t.is(componentFiles.length, 2)
}
})
test('uninstalls multiple components', async (t) => {
await packageManager.syncComponents(modifiers.map((modifier) => fakeComponent({ deleted: true, modifier })))
await new Promise((resolve) => setTimeout(resolve, 200))
const files = await fs.readdir(contentDir)
t.deepEqual(files, ['mapping.json'])
t.deepEqual(await readJSONFile(path.join(contentDir, 'mapping.json')), {})
})
test("doesn't download anything when two install/uninstall tasks are queued", async (t) => {
await Promise.all([
packageManager.syncComponents([fakeComponent({ deleted: false })]),
packageManager.syncComponents([fakeComponent({ deleted: false })]),
packageManager.syncComponents([fakeComponent({ deleted: true })]),
])
t.is(downloadFileCallCount, 1)
})
test("Relies on download_url's version field to store the version number", async (t) => {
await packageManager.syncComponents([fakeComponent()])
await new Promise((resolve) => setTimeout(resolve, 200))
const mappingFileVersion = JSON.parse(await fs.readFile(path.join(contentDir, 'mapping.json'), 'utf8'))[uuid].version
const packageJsonVersion = JSON.parse(
await fs.readFile(path.join(contentDir, identifier, 'package.json'), 'utf-8'),
).version
t.not(mappingFileVersion, packageJsonVersion)
t.is(mappingFileVersion, version)
})

View File

@@ -0,0 +1,38 @@
import anyTest, { TestFn } from 'ava'
import { Driver, createDriver } from './driver'
const StoreKeys = {
SelectedSpellCheckerLanguageCodes: 'selectedSpellCheckerLanguageCodes',
}
const test = anyTest as TestFn<Driver>
test.before(async (t) => {
t.context = await createDriver()
})
test.after.always(async (t) => {
await t.context.stop()
})
if (process.platform === 'darwin') {
test('does not create a manager on Mac', async (t) => {
t.falsy(await t.context.spellchecker.manager())
})
} else {
const language = 'cs'
test("adds a clicked language menu item to the store and session's languages", async (t) => {
await t.context.appMenu.clickLanguage(language as any)
const data = await t.context.storage.dataOnDisk()
t.true(data[StoreKeys.SelectedSpellCheckerLanguageCodes].includes(language))
t.true((await t.context.spellchecker.languages()).includes(language))
})
test("removes a clicked language menu item to the store's and session's languages", async (t) => {
await t.context.appMenu.clickLanguage(language as any)
const data = await t.context.storage.dataOnDisk()
t.false(data[StoreKeys.SelectedSpellCheckerLanguageCodes].includes(language))
t.false((await t.context.spellchecker.languages()).includes(language))
})
}

View File

@@ -0,0 +1,130 @@
import anyTest, { TestFn, ExecutionContext } from 'ava'
import fs from 'fs'
import path from 'path'
import proxyquire from 'proxyquire'
import { timeout } from '../app/javascripts/Main/Utils/Utils'
import { createDriver, Driver } from './driver'
const { serializeStoreData } = proxyquire('../app/javascripts/Main/Store', {
'./backupsManager': {
'@noCallThru': true,
},
'@electron': {
'@noCallThru': true,
},
'@electron/remote': {
'@noCallThru': true,
},
})
async function validateData(t: ExecutionContext<Driver>) {
const data = await t.context.storage.dataOnDisk()
/**
* There should always be 8 values in the store.
* If one was added/removed intentionally, update this number
*/
const numberOfStoreKeys = 10
t.is(Object.keys(data).length, numberOfStoreKeys)
t.is(typeof data.isMenuBarVisible, 'boolean')
t.is(typeof data.useSystemMenuBar, 'boolean')
t.is(typeof data.backupsDisabled, 'boolean')
t.is(typeof data.minimizeToTray, 'boolean')
t.is(typeof data.enableAutoUpdates, 'boolean')
t.is(typeof data.zoomFactor, 'number')
t.true(data.zoomFactor > 0)
t.is(typeof data.extServerHost, 'string')
/** Must not throw */
const extServerHost = new URL(data.extServerHost)
t.is(extServerHost.hostname, '127.0.0.1')
t.is(extServerHost.protocol, 'http:')
t.is(extServerHost.port, '45653')
t.is(typeof data.backupsLocation, 'string')
t.is(data.useNativeKeychain, null)
if (process.platform === 'darwin') {
t.is(data.selectedSpellCheckerLanguageCodes, null)
} else {
t.true(Array.isArray(data.selectedSpellCheckerLanguageCodes))
for (const language of data.selectedSpellCheckerLanguageCodes) {
t.is(typeof language, 'string')
}
}
}
const test = anyTest as TestFn<Driver>
test.beforeEach(async (t) => {
t.context = await createDriver()
})
test.afterEach.always((t) => {
return t.context.stop()
})
test('has valid data', async (t) => {
await validateData(t)
})
test('recreates a missing data file', async (t) => {
const location = await t.context.storage.dataLocation()
/** Delete the store's backing file */
await fs.promises.unlink(location)
await t.context.restart()
await validateData(t)
})
test('recovers from corrupted data', async (t) => {
const location = await t.context.storage.dataLocation()
/** Write bad data in the store's file */
await fs.promises.writeFile(location, '\uFFFF'.repeat(300))
await t.context.restart()
await validateData(t)
})
test('persists changes to disk after setting a value', async (t) => {
const factor = 4.8
await t.context.storage.setZoomFactor(factor)
const diskData = await t.context.storage.dataOnDisk()
t.is(diskData.zoomFactor, factor)
})
test('serializes string sets to an array', (t) => {
t.deepEqual(
serializeStoreData({
set: new Set(['value']),
} as any),
JSON.stringify({
set: ['value'],
}),
)
})
test('deletes local storage data after signing out', async (t) => {
function readLocalStorageContents() {
return fs.promises.readFile(path.join(t.context.userDataPath, 'Local Storage', 'leveldb', '000003.log'), {
encoding: 'utf8',
})
}
await t.context.windowLoaded
await t.context.storage.setLocalStorageValue('foo', 'bar')
let localStorageContents = await readLocalStorageContents()
t.is(localStorageContents.includes('foo'), true)
t.is(localStorageContents.includes('bar'), true)
await timeout(1_000)
await t.context.window.signOut()
await timeout(1_000)
localStorageContents = await readLocalStorageContents()
t.is(localStorageContents.includes('foo'), false)
t.is(localStorageContents.includes('bar'), false)
})

View File

@@ -0,0 +1,21 @@
import path from 'path'
import { deleteDir, ensureDirectoryExists } from '../app/javascripts/Main/Utils/FileUtils'
export function createTmpDir(name: string): {
path: string
make(): Promise<string>
clean(): Promise<void>
} {
const tmpDirPath = path.join(__dirname, 'data', 'tmp', path.basename(name))
return {
path: tmpDirPath,
async make() {
await ensureDirectoryExists(tmpDirPath)
return tmpDirPath
},
async clean() {
await deleteDir(tmpDirPath)
},
}
}

View File

@@ -0,0 +1,21 @@
import anyTest, { TestFn } from 'ava'
import { createDriver, Driver } from './driver'
const test = anyTest as TestFn<Driver>
test.beforeEach(async (t) => {
t.context = await createDriver()
})
test.afterEach.always(async (t) => {
await t.context.stop()
})
test('has auto-updates enabled by default', async (t) => {
t.true(await t.context.updates.autoUpdateEnabled())
})
test('reloads the menu after checking for an update', async (t) => {
await t.context.updates.check()
t.true(await t.context.appMenu.hasReloaded())
})

View File

@@ -0,0 +1,14 @@
import test from 'ava'
import { lowercaseDriveLetter } from '../app/javascripts/Main/Utils/Utils'
test("lowerCaseDriverLetter converts the drive letter of a given file's path to lower case", (t) => {
t.is(lowercaseDriveLetter('/C:/Lansing'), '/c:/Lansing')
t.is(lowercaseDriveLetter('/c:/Bone Rage'), '/c:/Bone Rage')
t.is(lowercaseDriveLetter('/C:/Give/Us/the/Gold'), '/c:/Give/Us/the/Gold')
})
test('lowerCaseDriverLetter only changes a single drive letter', (t) => {
t.is(lowercaseDriveLetter('C:/Hold Me In'), 'C:/Hold Me In')
t.is(lowercaseDriveLetter('/Cd:/Egg Replacer'), '/Cd:/Egg Replacer')
t.is(lowercaseDriveLetter('/C:radle of Rocks'), '/C:radle of Rocks')
})

View File

@@ -0,0 +1,16 @@
import anyTest, { TestFn } from 'ava'
import { createDriver, Driver } from './driver'
const test = anyTest as TestFn<Driver>
test.before(async (t) => {
t.context = await createDriver()
})
test.after.always((t) => {
return t.context.stop()
})
test('Only has one window', async (t) => {
t.is(await t.context.windowCount(), 1)
})