Преглед на файлове

continued working on tests.

Tomi Cvetic преди 6 години
родител
ревизия
2161516c3d

+ 3 - 1
backend/.vscode/settings.json

@@ -1,3 +1,5 @@
 {
-  "python.pythonPath": "./src/python_workers/venv/bin/python"
+  "python.pythonPath": "./src/python_workers/venv/bin/python",
+  "editor.tabSize": 2,
+  "sync.gist": "f2c23f606e2999daad6f9113382f4511"
 }

+ 53 - 0
backend/__tests__/src/__mocks__/child_process.js

@@ -0,0 +1,53 @@
+const mockServer = []
+
+function addCRPair (pair) {
+  mockServer.push(pair)
+}
+
+function spawn (command, args) {
+  return {
+    stdout: {
+      on: (event, callback) => {
+        if (event !== 'data') throw new Error('Only "data" is supported')
+        this.stdoutCallback = callback
+        callback(JSON.stringify({ data: 'ready' }))
+      }
+    },
+    stdin: {
+      write: string => {
+        if (!(typeof string === 'string')) {
+          throw new Error('Expecting a string')
+        }
+        const resp = mockServer.find(cr => cr.input === string)
+        if (!resp) {
+          this.stdoutCallback(
+            JSON.stringify({
+              error: {
+                string: 'Command not found',
+                type: 'Error',
+                filename: 'test1_worker.py',
+                linenumber: '33'
+              }
+            })
+          )
+        }
+      }
+    },
+    stderr: {
+      on: (event, callback) => {
+        if (event !== 'data') throw new Error('Only "data" is supported')
+        this.stderrCallback = callback
+      }
+    },
+    on: (event, callback) => {
+      if (event !== 'close' && event !== 'error') {
+        throw new Error('Only "close" and "error" are supported')
+      }
+      this.stderrCallback = callback
+    },
+    end: () => {},
+    kill: signal => {}
+  }
+}
+
+module.exports = { spawn, addCRPair }

+ 27 - 15
backend/__tests__/src/__mocks__/fs.js

@@ -1,21 +1,33 @@
-const path = require('path')
-const fs = jest.genMockFromModule('fs')
-
-let mockFiles = {}
-function __setMockFiles (newMockFiles) {
-  mockFiles = newMockFiles
-}
+const now = new Date('1980-03-18T04:15:00')
+const python_workers = [
+  { name: 'test1_worker.py', mtime: now, size: 1234 },
+  { name: 'test2_worker.py', mtime: now, size: 4321 }
+]
 
 function readdir (dirPath, callback) {
-  callback(null, mockFiles)
+  if (dirPath.endsWith('python_workers')) {
+    callback(null, python_workers.map(worker => worker.name))
+  } else if (dirPath === '/') {
+    callback(null, [])
+  } else {
+    callback(
+      new Error(`ENOENT: no such file or directory, scandir '${dirPath}'`),
+      null
+    )
+  }
 }
 
-function stat (file, callback) {
-  callback(null, { bugu: 12 })
+function stat (filename, callback) {
+  const file = python_workers.find(worker => filename.endsWith(worker.name))
+  if (!file) {
+    callback(
+      new Error(`ENOENT: no such file or directory, scandir '${filename}'`),
+      null
+    )
+  } else {
+    const { filename, ...stats } = file
+    callback(null, stats)
+  }
 }
 
-fs.__setMockFiles = __setMockFiles
-fs.readdir = readdir
-fs.stat = stat
-
-module.exports = fs
+module.exports = { readdir, stat }

+ 1 - 0
backend/__tests__/src/__mocks__/os.js

@@ -0,0 +1 @@
+module.exports = { hostname: () => 'fakehost' }

+ 86 - 7
backend/__tests__/src/interfaces.spec.js

@@ -1,12 +1,15 @@
 jest.mock('fs')
-const { resolvers, typeDefs } = require('../../src/interfaces')
+jest.mock('child_process')
+jest.mock('os')
+const md5 = require('md5')
+const { resolvers, typeDefs, __test__ } = require('../../src/interfaces')
+const child_process = require('child_process')
+child_process.addCRPair({
+  input: 'options',
+  output: "[{'option1':'value1'},{'option2':'value2'}]"
+})
 
 describe('interfaces module', () => {
-  const MOCK_FILES = ['serial_worker.py', 'usbtmc_worker.py']
-
-  beforeEach(() => {
-    require('fs').__setMockFiles(MOCK_FILES)
-  })
   it('exports resolvers and typeDefs', () => {
     expect(resolvers).toBeDefined()
     expect(typeDefs).toBeDefined()
@@ -16,8 +19,84 @@ describe('interfaces module', () => {
     expect(resolvers).toHaveProperty('Mutation')
   })
 
+  it('finds workers', async () => {
+    const { findWorkers } = __test__
+    await expect(findWorkers('/')).resolves.toEqual([])
+    await expect(findWorkers('python_workers')).resolves.toEqual([
+      'test1_worker.py',
+      'test2_worker.py'
+    ])
+    await expect(findWorkers()).rejects.toThrow(
+      'Directory argument must be a string.'
+    )
+    await expect(findWorkers('/DOESNTEXIST')).rejects.toThrow(
+      `ENOENT: no such file or directory, scandir '/DOESNTEXIST'`
+    )
+  })
+
+  it('finds options', async () => {
+    const { getOptions } = __test__
+    const options = [{ option1: 'value1' }]
+    const send = jest.fn()
+    const workerProcess = { send }
+
+    // 1. Check the use case
+    send.mockReturnValueOnce(
+      new Promise((resolve, reject) => {
+        resolve({ data: options })
+      })
+    )
+    await expect(getOptions(workerProcess)).resolves.toEqual(options)
+
+    // 2. Check empty option case
+    expect(workerProcess.send).toHaveBeenCalledWith({ type: 'options' })
+    send.mockReturnValueOnce(
+      new Promise((resolve, reject) => {
+        resolve({ data: [] })
+      })
+    )
+    await expect(getOptions(workerProcess)).resolves.toEqual([])
+
+    // 3. Check error case
+    expect(workerProcess.send).toHaveBeenCalledWith({ type: 'options' })
+    send.mockReturnValueOnce(
+      new Promise((resolve, reject) => {
+        resolve({ error: 'Timeout' })
+      })
+    )
+    await expect(getOptions(workerProcess)).rejects.toThrow('Timeout')
+
+    // 4. Throw error if workerProcess doesn't have a "send" property.
+    await expect(getOptions()).rejects.toThrow(
+      'workerProcess not configured properly.'
+    )
+  })
+
+  it('generates interface', async () => {
+    const { createInterface } = __test__
+    await expect(createInterface()).rejects.toThrow(
+      'Directory argument must be a string.'
+    )
+    await expect(
+      createInterface('python_workers', 'testXXX_worker.py')
+    ).rejects.toThrow(
+      `ENOENT: no such file or directory, scandir 'python_workers/testXXX_worker.py'`
+    )
+    await expect(
+      createInterface('python_workers', 'test1_worker.py')
+    ).resolves.toMatchObject({
+      id: md5(`test1python_workers/test1_worker.py1980-03-18T03:15:00.000Z`),
+      interfaceName: 'test1',
+      options: [],
+      workerProcess: {
+        error: []
+      },
+      dini: 12
+    })
+  })
+
   const { Query, Mutation, Interface, Connection } = resolvers
-  it('finds interfaces', async () => {
+  xit('finds interfaces', async () => {
     const resp = await Query.interfaces()
     expect(resp).toBe([])
   })

+ 18 - 6
backend/index.js

@@ -8,6 +8,19 @@
  * Configure CORS for use with localhost.
  */
 
+const quickMiddleware = (req, res, next) => {
+  if (
+    req.body &&
+    req.body.operationName &&
+    req.body.operationName !== 'IntrospectionQuery' &&
+    req.body.variables &&
+    req.body.query
+  ) {
+    console.log(req.body.operationName, req.body.variables)
+  }
+  next()
+}
+
 require('dotenv').config()
 const { GraphQLServer } = require('graphql-yoga')
 const cookieParser = require('cookie-parser')
@@ -23,11 +36,7 @@ const prismaResolvers = require('./src/resolvers')
 const system = require('./src/system')
 const interfaces = require('./src/interfaces')
 
-const typeDefs = [
-  './schema.graphql',
-  system.typeDefs,
-  interfaces.typeDefs
-]
+const typeDefs = ['./schema.graphql', system.typeDefs, interfaces.typeDefs]
 
 const resolvers = merge(
   system.resolvers,
@@ -47,9 +56,12 @@ const server = new GraphQLServer({
 
 server.express.use(cookieParser())
 server.express.use(bodyParser.json())
+server.express.use(quickMiddleware)
 server.express.use(authenticate)
 server.express.use(populateUser)
-server.express.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }))
+server.express.use(
+  cors({ origin: process.env.FRONTEND_URL, credentials: true })
+)
 server.express.use('/static', express.static('static'))
 server.express.post('/upload', uploadMiddleware.single('file'), handleFile)
 

Файловите разлики са ограничени, защото са твърде много
+ 152 - 26
backend/package-lock.json


+ 2 - 1
backend/package.json

@@ -40,7 +40,8 @@
   },
   "jest": {
     "testPathIgnorePatterns": [
-      "<rootDir>/node_modules/"
+      "<rootDir>/node_modules/",
+      "__mocks__/"
     ],
     "transform": {
       "\\.(gql|graphql)$": "jest-transform-graphql",

+ 95 - 44
backend/src/interfaces.js

@@ -1,4 +1,6 @@
-const fs = require('fs')
+/** @module interfaces */
+
+let fs = require('fs')
 const os = require('os')
 const md5 = require('md5')
 const { promisify } = require('util')
@@ -20,9 +22,13 @@ const stat = promisify(fs.stat)
  *  * Connection:
  */
 
+/** Directory with the Python worker scripts */
 const WORKER_DIR = `${__dirname}/python_workers`
+
+/** Hostname is used to identify ports across machines */
 const HOST = os.hostname()
 
+/** Local state for the interface module */
 const state = {
   interfaces: [],
   ports: [],
@@ -34,6 +40,7 @@ const state = {
   }
 }
 
+/** GraphQL types for the interface module */
 const typeDefs = `
   type Interface {
     id: ID!
@@ -104,6 +111,79 @@ const typeDefs = `
     sendCommand(connectionId: ID!, command: ConnectionCommand!): String!
   }
 `
+/**
+ * Find worker files in a directory based on filename convention
+ * (ending in _worker.py).
+ * @param {string} directory - Python worker directory
+ */
+async function findWorkers (directory) {
+  // Only accept strings
+  if (!(typeof directory === 'string')) {
+    throw new Error('Directory argument must be a string.')
+  }
+  const fileNames = await readdir(directory)
+  return fileNames.filter(fileName => fileName.includes('_worker.py'))
+}
+
+/**
+ * Fetch options from Python worker process.
+ * @param {Object} workerProcess - Worker process to use for fetching the options
+ */
+async function getOptions (workerProcess) {
+  // Check that workerProcess has a send method.
+  if (!workerProcess || !workerProcess.send) throw new Error('workerProcess not configured properly.')
+  const { data, error } = await workerProcess.send({ type: 'options' })
+  if (error) throw new Error(error)
+  return data
+}
+
+/**
+ * Takes a Python worker script and generates an interface for it.
+ * You need to call the spawn() function to launch the Python worker
+ * process.
+ * @param {string} directory - Python worker directory
+ * @param {string} workerFile - Python worker file
+ */
+async function createInterface (directory, workerFile, state) {
+  // Assert that arguments are strings.
+  if (!(typeof directory === 'string')) {
+    throw new Error('Directory argument must be a string.')
+  }
+  if (!(typeof workerFile === 'string')) {
+    throw new Error('workerFile argument must be a string.')
+  }
+  const interfaceName = workerFile.replace(/_worker\.py/, '')
+  const path = `${directory}/${workerFile}`
+  // 1. Find out the last modification time.
+  const { mtime } = await stat(path)
+  const workerScript = { path, mtime, updated: null }
+  // 2. Check if the interface already exists
+  console.log('bugu', state)
+  const foundInterface = state.interfaces.find(
+    iface => iface.interfaceName === interfaceName
+  )
+  if (foundInterface) {
+    // b. If it was modified, save the modification time.
+    if (foundInterface.workerScript.mtime < mtime) {
+      foundInterface.workerScript.updated = mtime
+    }
+    return
+  }
+  // c. Spawn a new worker connection.
+  const workerProcess = new PythonWorker(workerScript)
+  const { data, error } = await workerProcess.spawn()
+  if (error) throw new Error(error)
+  const id = md5(`${interfaceName}${workerScript.path}${mtime.toISOString()}`)
+  const options = await getOptions(workerProcess)
+  // d. Save the worker in the state.
+  return {
+    id,
+    interfaceName,
+    workerScript,
+    workerProcess,
+    options
+  }
+}
 
 /**
  * INTERFACE SECTION
@@ -114,42 +194,13 @@ async function findInterfaces () {
   // state.lastScan.workers = Date.now()
 
   // 2. Find all files in ./python_workers that end in _worker.py
-  const fileNames = await readdir(WORKER_DIR)
-  const workerFiles = fileNames.filter(fileName =>
-    fileName.includes('_worker.py')
-  )
+  const workerFiles = await findWorkers(WORKER_DIR)
 
   // 3. For every worker script
-  const workerPromises = workerFiles.map(async workerFile => {
-    const interfaceName = workerFile.replace(/_worker\.py/, '')
-    const path = `${WORKER_DIR}/${workerFile}`
-    // a. Find out if it was modified.
-    const { mtime } = await stat(path)
-    const workerScript = { path, mtime, updated: null }
-    const foundInterface = state.interfaces.find(
-      iface => iface.interfaceName === interfaceName
-    )
-    if (foundInterface) {
-      // b. If it was modified, save the modification time.
-      if (foundInterface.workerScript.mtime < mtime) {
-        foundInterface.workerScript.updated = mtime
-      }
-      return
-    }
-    // c. Spawn a new worker connection.
-    const workerProcess = new PythonWorker(workerScript)
-    const { data, error } = await workerProcess.spawn()
-    if (error) throw new Error(error)
-    // d. Save the worker in the state.
-    state.interfaces.push({
-      id: md5(`${interfaceName}${workerScript}${mtime}`),
-      interfaceName,
-      workerScript,
-      workerProcess,
-      options: await options(workerProcess)
-    })
-  })
-  await Promise.all(workerPromises)
+  const workerPromises = workerFiles.map(workerFile =>
+    createInterface(WORKER_DIR, workerFile, state)
+  )
+  await Promise.all(workerPromises).then(results => console.log(results))
 }
 
 async function interfaces (parent, args, context, info) {
@@ -171,14 +222,6 @@ async function iface (parent, { id }, context, info) {
   }
 }
 
-async function options (workerProcess) {
-  const { data, error } = await workerProcess.send({
-    type: 'options'
-  })
-  if (error) throw new Error(error)
-  return data
-}
-
 function workerProcess (parent, args, context, info) {
   const {
     killed,
@@ -363,4 +406,12 @@ const resolvers = {
   }
 }
 
-module.exports = { typeDefs, resolvers }
+const __test__ = {
+  findWorkers,
+  createInterface,
+  getOptions,
+  workerProcess,
+  state
+}
+
+module.exports = { typeDefs, resolvers, __test__ }

+ 6 - 6
backend/src/pythonWorker.js

@@ -1,4 +1,4 @@
-const { spawn } = require('child_process')
+let { spawn } = require('child_process')
 const { Lock } = require('semaphore-async-await')
 const { serializeError } = require('./utils')
 
@@ -40,7 +40,7 @@ function PythonWorker (workerScript, shellOptions) {
     if (!this.isProcessRunning) return { error: 'Process not running' }
 
     return this.transaction(command => {
-      console.log('[STDIN]', command)
+      // console.log('[STDIN]', command)
       this.pythonShell.stdin.write(
         // Write the command as a JSON object, end with a newline!
         JSON.stringify(command) + '\n'
@@ -59,22 +59,22 @@ function PythonWorker (workerScript, shellOptions) {
       this.pythonShell.stdout.on('data', message => {
         // The python worker returns JSON {data, error}
         const parsed = JSON.parse(message)
-        console.log('[STDOUT]', parsed)
+        // console.log('[STDOUT]', parsed)
         this.data.push(parsed)
         this.pythonLock.release()
       })
       this.pythonShell.stderr.on('data', error => {
-        console.log('[STDERR]', error)
+        // console.log('[STDERR]', error)
         this.error.push({ error })
         this.pythonLock.release()
       })
       this.pythonShell.on('close', exitCode => {
-        console.log('[CLOSE]', exitCode)
+        // console.log('[CLOSE]', exitCode)
         this.data.push({ data: exitCode })
         this.pythonLock.release()
       })
       this.pythonShell.on('error', error => {
-        console.log('[ERROR]', error)
+        // console.log('[ERROR]', error)
         this.error.push({ error })
         this.pythonLock.release()
       })

Някои файлове не бяха показани, защото твърде много файлове са промени