OILS / test / sh_spec.py View on Github | oils.pub

1517 lines, 940 significant
1#!/usr/bin/env python2
2from __future__ import print_function
3"""
4sh_spec.py -- Test framework to compare shells.
5
6Assertion help:
7 stdout: A single line of expected stdout. Newline is implicit.
8 stdout-json: JSON-encoded string. Use for the empty string (no newline),
9 for unicode chars, etc.
10
11 stderr: Ditto for stderr stream.
12 status: Expected shell return code. If not specified, the case must exit 0.
13
14Results:
15 PASS - we got the ideal, expected value
16 OK - we got a value that was not ideal, but expected
17 For OSH this is behavior that was defined to be different?
18 N-I - Not implemented (e.g. $''). Assertions still checked (in case it
19 starts working)
20 BUG - we verified the value of a known bug
21 FAIL - we got an unexpected value. If the implementation can't be changed,
22 it should be converted to BUG or OK. Otherwise it should be made to
23 PASS.
24
25NOTE: The difference between OK and BUG is a matter of judgement. If the ideal
26behavior is a compile time error (code 2), a runtime error is generally OK.
27
28If ALL shells agree on a broken behavior, they are all marked OK (but our
29implementation will be PASS.) But if the behavior is NOT POSIX compliant, then
30it will be a BUG.
31
32If one shell disagrees with others, that is generally a BUG.
33
34Example test case:
35
36#### hello and fail
37echo hello
38echo world
39exit 1
40## status: 1
41#
42# ignored comment
43#
44## STDOUT:
45hello
46world
47## END
48
49"""
50
51import collections
52import cgi
53import cStringIO
54import errno
55import json
56import optparse
57import os
58import pprint
59import re
60import shutil
61import subprocess
62import sys
63
64from test import spec_lib
65from doctools import html_head
66
67log = spec_lib.log
68
69# Magic strings for other variants of OSH.
70
71# NOTE: osh_ALT is usually _bin/osh -- the release binary.
72# It would be better to rename these osh-cpython and osh-ovm. Have the concept
73# of a suffix?
74
75OSH_CPYTHON = ('osh', 'osh-dbg')
76OTHER_OSH = ('osh_ALT', )
77
78YSH_CPYTHON = ('ysh', 'ysh-dbg')
79OTHER_YSH = ('oil_ALT', )
80
81# For now, only count the Oils CPython failures. TODO: the spec-cpp job should
82# assert the osh-cpp and ysh-cpp deltas.
83OTHER_OILS = OTHER_OSH + OTHER_YSH + ('osh-cpp', 'ysh-cpp')
84
85
86class ParseError(Exception):
87 pass
88
89
90# EXAMPLES:
91## stdout: foo
92## stdout-json: ""
93#
94# In other words, it could be (name, value) or (qualifier, name, value)
95
96KEY_VALUE_RE = re.compile(
97 r'''
98 [#][#] \s+
99 # optional prefix with qualifier and shells
100 (?: (OK|BUG|N-I) \s+ ([\w+/]+) \s+ )?
101 ([\w\-]+) # key
102 :
103 \s* (.*) # value
104''', re.VERBOSE)
105
106END_MULTILINE_RE = re.compile(r'''
107 [#][#] \s+ END
108''', re.VERBOSE)
109
110# Line types
111TEST_CASE_BEGIN = 0 # Starts with ####
112KEY_VALUE = 1 # Metadata
113KEY_VALUE_MULTILINE = 2 # STDOUT STDERR
114END_MULTILINE = 3 # STDOUT STDERR
115PLAIN_LINE = 4 # Uncommented
116EOF = 5
117
118LEX_OUTER = 0 # Ignore blank lines, e.g. for separating cases
119LEX_RAW = 1 # Blank lines are significant
120
121
122class Tokenizer(object):
123 """Modal lexer!"""
124
125 def __init__(self, f):
126 self.f = f
127
128 self.cursor = None
129 self.line_num = 0
130
131 self.next()
132
133 def _ClassifyLine(self, line, lex_mode):
134 if not line: # empty
135 return self.line_num, EOF, ''
136
137 if lex_mode == LEX_OUTER and not line.strip():
138 return None
139
140 if line.startswith('####'):
141 desc = line[4:].strip()
142 return self.line_num, TEST_CASE_BEGIN, desc
143
144 m = KEY_VALUE_RE.match(line)
145 if m:
146 qualifier, shells, name, value = m.groups()
147 # HACK: Expected data should have the newline.
148 if name in ('stdout', 'stderr'):
149 value += '\n'
150
151 if name in ('STDOUT', 'STDERR'):
152 token_type = KEY_VALUE_MULTILINE
153 else:
154 token_type = KEY_VALUE
155 return self.line_num, token_type, (qualifier, shells, name, value)
156
157 m = END_MULTILINE_RE.match(line)
158 if m:
159 return self.line_num, END_MULTILINE, None
160
161 # If it starts with ##, it should be metadata. This finds some typos.
162 if line.lstrip().startswith('##'):
163 raise RuntimeError('Invalid ## line %r' % line)
164
165 if line.lstrip().startswith('#'): # Ignore comments
166 return None # try again
167
168 # Non-empty line that doesn't start with '#'
169 # NOTE: We need the original line to test the whitespace sensitive <<-.
170 # And we need rstrip because we add newlines back below.
171 return self.line_num, PLAIN_LINE, line
172
173 def next(self, lex_mode=LEX_OUTER):
174 """Raises StopIteration when exhausted."""
175 while True:
176 line = self.f.readline()
177 self.line_num += 1
178
179 tok = self._ClassifyLine(line, lex_mode)
180 if tok is not None:
181 break
182
183 self.cursor = tok
184 return self.cursor
185
186 def peek(self):
187 return self.cursor
188
189
190def AddMetadataToCase(case, qualifier, shells, name, value, line_num):
191 shells = shells.split('/') # bash/dash/mksh
192 for shell in shells:
193 if shell not in case:
194 case[shell] = {}
195
196 # Check a duplicate specification
197 name_without_type = re.sub(r'-(json|repr)$', '', name)
198 if (name_without_type in case[shell] or
199 name_without_type + '-json' in case[shell] or
200 name_without_type + '-repr' in case[shell]):
201 raise ParseError('Line %d: duplicate spec %r for %r' %
202 (line_num, name, shell))
203
204 # Check inconsistent qualifier
205 if 'qualifier' in case[shell] and qualifier != case[shell]['qualifier']:
206 raise ParseError(
207 'Line %d: inconsistent qualifier %r is specified for %r, '
208 'but %r was previously specified. '
209 % (line_num, qualifier, shell, case[shell]['qualifier']))
210
211 case[shell][name] = value
212 case[shell]['qualifier'] = qualifier
213
214
215# Format of a test script.
216#
217# -- Code is either literal lines, or a commented out code: value.
218# code = PLAIN_LINE*
219# | '## code:' VALUE
220#
221# -- Key value pairs can be single- or multi-line
222# key_value = '##' KEY ':' VALUE
223# | KEY_VALUE_MULTILINE PLAIN_LINE* END_MULTILINE
224#
225# -- Description, then key-value pairs surrounding code.
226# test_case = '####' DESC
227# key_value*
228# code
229# key_value*
230#
231# -- Should be a blank line after each test case. Leading comments and code
232# -- are OK.
233#
234# test_file =
235# key_value* -- file level metadata
236# (test_case '\n')*
237
238
239def ParseKeyValue(tokens, case):
240 """Parse commented-out metadata in a test case.
241
242 The metadata must be contiguous.
243
244 Args:
245 tokens: Tokenizer
246 case: dictionary to add to
247 """
248 while True:
249 line_num, kind, item = tokens.peek()
250
251 if kind == KEY_VALUE_MULTILINE:
252 qualifier, shells, name, empty_value = item
253 if empty_value:
254 raise ParseError(
255 'Line %d: got value %r for %r, but the value should be on the '
256 'following lines' % (line_num, empty_value, name))
257
258 value_lines = []
259 while True:
260 tokens.next(lex_mode=LEX_RAW) # empty lines aren't skipped
261 _, kind2, item2 = tokens.peek()
262 if kind2 != PLAIN_LINE:
263 break
264 value_lines.append(item2)
265
266 value = ''.join(value_lines)
267
268 name = name.lower() # STDOUT -> stdout
269 if qualifier:
270 AddMetadataToCase(case, qualifier, shells, name, value,
271 line_num)
272 else:
273 case[name] = value
274
275 # END token is optional.
276 if kind2 == END_MULTILINE:
277 tokens.next()
278
279 elif kind == KEY_VALUE:
280 qualifier, shells, name, value = item
281
282 if qualifier:
283 AddMetadataToCase(case, qualifier, shells, name, value,
284 line_num)
285 else:
286 case[name] = value
287
288 tokens.next()
289
290 else: # Unknown token type
291 break
292
293
294def ParseCodeLines(tokens, case):
295 """Parse uncommented code in a test case."""
296 _, kind, item = tokens.peek()
297 if kind != PLAIN_LINE:
298 raise ParseError('Expected a line of code (got %r, %r)' % (kind, item))
299 code_lines = []
300 while True:
301 _, kind, item = tokens.peek()
302 if kind != PLAIN_LINE:
303 case['code'] = ''.join(code_lines)
304 return
305 code_lines.append(item)
306 tokens.next(lex_mode=LEX_RAW)
307
308
309def ParseTestCase(tokens):
310 """Parse a single test case and return it.
311
312 If at EOF, return None.
313 """
314 line_num, kind, item = tokens.peek()
315 if kind == EOF:
316 return None
317
318 if kind != TEST_CASE_BEGIN:
319 raise RuntimeError("line %d: Expected TEST_CASE_BEGIN, got %r" %
320 (line_num, [kind, item]))
321
322 tokens.next()
323
324 case = {'desc': item, 'line_num': line_num}
325
326 ParseKeyValue(tokens, case)
327
328 # For broken code
329 if 'code' in case: # Got it through a key value pair
330 return case
331
332 ParseCodeLines(tokens, case)
333 ParseKeyValue(tokens, case)
334
335 return case
336
337
338_META_FIELDS = [
339 'our_shell',
340 'compare_shells',
341 'suite',
342 'tags',
343 'oils_failures_allowed',
344 'oils_cpp_failures_allowed',
345]
346
347
348def ParseTestFile(test_file, tokens):
349 """
350 test_file: Only for error message
351 """
352 file_metadata = {}
353 test_cases = []
354
355 try:
356 # Skip over the header. Setup code can go here, although would we have to
357 # execute it on every case?
358 while True:
359 line_num, kind, item = tokens.peek()
360 if kind != KEY_VALUE:
361 break
362
363 qualifier, shells, name, value = item
364 if qualifier is not None:
365 raise RuntimeError('Invalid qualifier in spec file metadata')
366 if shells is not None:
367 raise RuntimeError('Invalid shells in spec file metadata')
368
369 file_metadata[name] = value
370
371 tokens.next()
372
373 while True: # Loop over cases
374 test_case = ParseTestCase(tokens)
375 if test_case is None:
376 break
377 test_cases.append(test_case)
378
379 except StopIteration:
380 raise RuntimeError('Unexpected EOF parsing test cases')
381
382 for name in file_metadata:
383 if name not in _META_FIELDS:
384 raise RuntimeError('Invalid file metadata %r in %r' %
385 (name, test_file))
386
387 return file_metadata, test_cases
388
389
390def CreateStringAssertion(d, key, assertions, qualifier=False):
391 found = False
392
393 exp = d.get(key)
394 if exp is not None:
395 a = EqualAssertion(key, exp, qualifier=qualifier)
396 assertions.append(a)
397 found = True
398
399 exp_json = d.get(key + '-json')
400 if exp_json is not None:
401 exp = json.loads(exp_json, encoding='utf-8')
402 a = EqualAssertion(key, exp, qualifier=qualifier)
403 assertions.append(a)
404 found = True
405
406 # For testing invalid unicode
407 exp_repr = d.get(key + '-repr')
408 if exp_repr is not None:
409 exp = eval(exp_repr)
410 a = EqualAssertion(key, exp, qualifier=qualifier)
411 assertions.append(a)
412 found = True
413
414 return found
415
416
417def CreateIntAssertion(d, key, assertions, qualifier=False):
418 exp = d.get(key) # expected
419 if exp is not None:
420 # For now, turn it into int
421 a = EqualAssertion(key, int(exp), qualifier=qualifier)
422 assertions.append(a)
423 return True
424 return False
425
426
427def CreateAssertions(case, sh_label):
428 """Given a raw test case and a shell label, create EqualAssertion instances
429 to run."""
430 assertions = []
431
432 # Whether we found assertions
433 stdout = False
434 stderr = False
435 status = False
436
437 # So the assertion are exactly the same for osh and osh_ALT
438
439 if sh_label.startswith('osh'):
440 case_sh = 'osh'
441 elif sh_label.startswith('bash'):
442 case_sh = 'bash'
443 else:
444 case_sh = sh_label
445
446 if case_sh in case:
447 q = case[case_sh]['qualifier']
448 if CreateStringAssertion(case[case_sh],
449 'stdout',
450 assertions,
451 qualifier=q):
452 stdout = True
453 if CreateStringAssertion(case[case_sh],
454 'stderr',
455 assertions,
456 qualifier=q):
457 stderr = True
458 if CreateIntAssertion(case[case_sh], 'status', assertions,
459 qualifier=q):
460 status = True
461
462 if not stdout:
463 CreateStringAssertion(case, 'stdout', assertions)
464 if not stderr:
465 CreateStringAssertion(case, 'stderr', assertions)
466 if not status:
467 if 'status' in case:
468 CreateIntAssertion(case, 'status', assertions)
469 else:
470 # If the user didn't specify a 'status' assertion, assert that the exit
471 # code is 0.
472 a = EqualAssertion('status', 0)
473 assertions.append(a)
474
475 no_traceback = SubstringAssertion('stderr', 'Traceback (most recent')
476 assertions.append(no_traceback)
477
478 #print 'SHELL', shell
479 #pprint.pprint(case)
480 #print(assertions)
481 return assertions
482
483
484class Result(object):
485 """Result of an stdout/stderr/status assertion or of a (case, shell) cell.
486
487 Order is important: the result of a cell is the minimum of the results of
488 each assertion.
489 """
490 TIMEOUT = 0 # ONLY a cell result, not an assertion result
491 FAIL = 1
492 BUG = 2
493 NI = 3
494 OK = 4
495 PASS = 5
496
497 length = 6 # for loops
498
499
500def QualifierToResult(qualifier):
501 # type: (str) -> Result
502 if qualifier == 'BUG': # equal, but known bad
503 return Result.BUG
504 if qualifier == 'N-I': # equal, and known UNIMPLEMENTED
505 return Result.NI
506 if qualifier == 'OK': # equal, but ok (not ideal)
507 return Result.OK
508 return Result.PASS # ideal behavior
509
510
511class EqualAssertion(object):
512 """Check that two values are equal."""
513
514 def __init__(self, key, expected, qualifier=None):
515 self.key = key
516 self.expected = expected # expected value
517 self.qualifier = qualifier # whether this was a special case?
518
519 def __repr__(self):
520 return '<EqualAssertion %s == %r>' % (self.key, self.expected)
521
522 def Check(self, shell, record):
523 actual = record[self.key]
524 if actual != self.expected:
525 if len(str(self.expected)) < 40:
526 msg = '[%s %s] Expected %r, got %r' % (shell, self.key,
527 self.expected, actual)
528 else:
529 msg = '''
530[%s %s]
531Expected %r
532Got %r
533''' % (shell, self.key, self.expected, actual)
534
535 # TODO: Make this better and add a flag for it.
536 if 0:
537 import difflib
538 for line in difflib.unified_diff(self.expected,
539 actual,
540 fromfile='expected',
541 tofile='actual'):
542 print(repr(line))
543
544 return Result.FAIL, msg
545
546 return QualifierToResult(self.qualifier), ''
547
548
549class SubstringAssertion(object):
550 """Check that a string like stderr doesn't have a substring."""
551
552 def __init__(self, key, substring):
553 self.key = key
554 self.substring = substring
555
556 def __repr__(self):
557 return '<SubstringAssertion %s == %r>' % (self.key, self.substring)
558
559 def Check(self, shell, record):
560 actual = record[self.key]
561 if self.substring in actual:
562 msg = '[%s %s] Found %r' % (shell, self.key, self.substring)
563 return Result.FAIL, msg
564 return Result.PASS, ''
565
566
567class Stats(object):
568
569 def __init__(self, num_cases, sh_labels):
570 self.counters = collections.defaultdict(int)
571 c = self.counters
572 c['num_cases'] = num_cases
573 c['oils_num_passed'] = 0
574 c['oils_num_failed'] = 0
575 c['oils_cpp_num_failed'] = 0
576 # Number of osh_ALT results that differed from osh.
577 c['oils_ALT_delta'] = 0
578
579 self.by_shell = {}
580 for sh in sh_labels:
581 self.by_shell[sh] = collections.defaultdict(int)
582 self.nonzero_results = collections.defaultdict(int)
583
584 self.tsv_rows = []
585
586 def Inc(self, counter_name):
587 self.counters[counter_name] += 1
588
589 def Get(self, counter_name):
590 return self.counters[counter_name]
591
592 def Set(self, counter_name, val):
593 self.counters[counter_name] = val
594
595 def ReportCell(self, case_num, cell_result, sh_label):
596 self.tsv_rows.append(
597 (str(case_num), sh_label, TEXT_CELLS[cell_result]))
598
599 self.by_shell[sh_label][cell_result] += 1
600 self.nonzero_results[cell_result] += 1
601
602 c = self.counters
603 if cell_result == Result.TIMEOUT:
604 c['num_timeout'] += 1
605 elif cell_result == Result.FAIL:
606 # Special logic: don't count osh_ALT because its failures will be
607 # counted in the delta.
608 if sh_label not in OTHER_OILS:
609 c['num_failed'] += 1
610
611 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
612 c['oils_num_failed'] += 1
613
614 if sh_label in ('osh-cpp', 'ysh-cpp'):
615 c['oils_cpp_num_failed'] += 1
616 elif cell_result == Result.BUG:
617 c['num_bug'] += 1
618 elif cell_result == Result.NI:
619 c['num_ni'] += 1
620 elif cell_result == Result.OK:
621 c['num_ok'] += 1
622 elif cell_result == Result.PASS:
623 c['num_passed'] += 1
624 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
625 c['oils_num_passed'] += 1
626 else:
627 raise AssertionError()
628
629 def WriteTsv(self, f):
630 f.write('case\tshell\tresult\n')
631 for row in self.tsv_rows:
632 f.write('\t'.join(row))
633 f.write('\n')
634
635
636PIPE = subprocess.PIPE
637
638
639def RunCases(cases, case_predicate, shells, env, out, opts):
640 """Run a list of test 'cases' for all 'shells' and write output to
641 'out'."""
642 if opts.trace:
643 for _, sh in shells:
644 log('\tshell: %s', sh)
645 print('\twhich $SH: ', end='', file=sys.stderr)
646 subprocess.call(['which', sh])
647
648 #pprint.pprint(cases)
649
650 if isinstance(case_predicate, spec_lib.RangePredicate) and (
651 case_predicate.begin > (len(cases) - 1)
652 ):
653 raise RuntimeError(
654 "valid case indexes are from 0 to %s. given range: %s-%s"
655 % ((len(cases) - 1), case_predicate.begin, case_predicate.end)
656 )
657
658 sh_labels = [sh_label for sh_label, _ in shells]
659
660 out.WriteHeader(sh_labels)
661 stats = Stats(len(cases), sh_labels)
662
663 # Make an environment for each shell. $SH is the path to the shell, so we
664 # can test flags, etc.
665 sh_env = []
666 for _, sh_path in shells:
667 e = dict(env)
668 e[opts.sh_env_var_name] = sh_path
669 sh_env.append(e)
670
671 # Determine which one (if any) is osh-cpython, for comparison against other
672 # shells.
673 osh_cpython_index = -1
674 for i, (sh_label, _) in enumerate(shells):
675 if sh_label in OSH_CPYTHON:
676 osh_cpython_index = i
677 break
678
679 timeout_dir = os.path.abspath('_tmp/spec/timeouts')
680 try:
681 shutil.rmtree(timeout_dir)
682 os.mkdir(timeout_dir)
683 except OSError:
684 pass
685
686 # Now run each case, and print a table.
687 for i, case in enumerate(cases):
688 line_num = case['line_num']
689 desc = case['desc']
690 code = case['code']
691
692 if opts.trace:
693 log('case %d: %s', i, desc)
694
695 if not case_predicate(i, case):
696 stats.Inc('num_skipped')
697 continue
698
699 if opts.do_print:
700 print('#### %s' % case['desc'])
701 print(case['code'])
702 print()
703 continue
704
705 stats.Inc('num_cases_run')
706
707 result_row = []
708
709 for shell_index, (sh_label, sh_path) in enumerate(shells):
710 timeout_file = os.path.join(timeout_dir, '%02d-%s' % (i, sh_label))
711 if opts.timeout:
712 if opts.timeout_bin:
713 # This is what smoosh itself uses. See smoosh/tests/shell_tests.sh
714 # QUIRK: interval can only be a whole number
715 argv = [
716 opts.timeout_bin,
717 '-t',
718 opts.timeout,
719 # Somehow I'm not able to get this timeout file working? I think
720 # it has a bug when using stdin. It waits for the background
721 # process too.
722
723 #'-i', '1',
724 #'-l', timeout_file
725 ]
726 else:
727 # This kills hanging tests properly, but somehow they fail with code
728 # -9?
729 #argv = ['timeout', '-s', 'KILL', opts.timeout]
730
731 # s suffix for seconds
732 argv = ['timeout', opts.timeout + 's']
733 else:
734 argv = []
735 argv.append(sh_path)
736
737 # dash doesn't support -o posix
738 if opts.posix and sh_label != 'dash':
739 argv.extend(['-o', 'posix'])
740
741 if opts.trace:
742 log('\targv: %s', ' '.join(argv))
743
744 case_env = sh_env[shell_index]
745
746 # Unique dir for every test case and shell
747 tmp_base = os.path.normpath(opts.tmp_env) # no . or ..
748 case_tmp_dir = os.path.join(tmp_base, '%02d-%s' % (i, sh_label))
749
750 try:
751 os.makedirs(case_tmp_dir)
752 except OSError as e:
753 if e.errno != errno.EEXIST:
754 raise
755
756 # Some tests assume _tmp exists
757 # TODO: get rid of this in the common case, to save inodes! I guess have
758 # an opt-in setting per FILE, like make_underscore_tmp: true.
759 try:
760 os.mkdir(os.path.join(case_tmp_dir, '_tmp'))
761 except OSError as e:
762 if e.errno != errno.EEXIST:
763 raise
764
765 case_env['TMP'] = case_tmp_dir
766
767 if opts.pyann_out_dir:
768 case_env = dict(case_env)
769 case_env['PYANN_OUT'] = os.path.join(opts.pyann_out_dir,
770 '%d.json' % i)
771
772 try:
773 p = subprocess.Popen(argv,
774 env=case_env,
775 cwd=case_tmp_dir,
776 stdin=PIPE,
777 stdout=PIPE,
778 stderr=PIPE)
779 except OSError as e:
780 print('Error running %r: %s' % (sh_path, e), file=sys.stderr)
781 sys.exit(1)
782
783 p.stdin.write(code)
784
785 actual = {}
786 actual['stdout'], actual['stderr'] = p.communicate()
787
788 actual['status'] = p.wait()
789
790 if opts.timeout_bin and os.path.exists(timeout_file):
791 cell_result = Result.TIMEOUT
792 elif not opts.timeout_bin and actual['status'] == 124:
793 cell_result = Result.TIMEOUT
794 else:
795 messages = []
796 cell_result = Result.PASS
797
798 # TODO: Warn about no assertions? Well it will always test the error
799 # code.
800 assertions = CreateAssertions(case, sh_label)
801 for a in assertions:
802 result, msg = a.Check(sh_label, actual)
803 # The minimum one wins.
804 # If any failed, then the result is FAIL.
805 # If any are OK, but none are FAIL, the result is OK.
806 cell_result = min(cell_result, result)
807 if msg:
808 messages.append(msg)
809
810 if cell_result != Result.PASS or opts.details:
811 d = (i, sh_label, actual['stdout'], actual['stderr'],
812 messages)
813 out.AddDetails(d)
814
815 result_row.append(cell_result)
816
817 stats.ReportCell(i, cell_result, sh_label)
818
819 if sh_label in OTHER_OSH:
820 # This is only an error if we tried to run ANY OSH.
821 if osh_cpython_index == -1:
822 raise RuntimeError(
823 "Couldn't determine index of osh-cpython")
824
825 other_result = result_row[shell_index]
826 cpython_result = result_row[osh_cpython_index]
827 if other_result != cpython_result:
828 stats.Inc('oils_ALT_delta')
829
830 out.WriteRow(i, line_num, result_row, desc)
831
832 return stats
833
834
835# ANSI color constants
836_RESET = '\033[0;0m'
837_BOLD = '\033[1m'
838
839_RED = '\033[31m'
840_GREEN = '\033[32m'
841_YELLOW = '\033[33m'
842_PURPLE = '\033[35m'
843
844TEXT_CELLS = {
845 Result.TIMEOUT: 'TIME',
846 Result.FAIL: 'FAIL',
847 Result.BUG: 'BUG',
848 Result.NI: 'N-I',
849 Result.OK: 'ok',
850 Result.PASS: 'pass',
851}
852
853ANSI_COLORS = {
854 Result.TIMEOUT: _PURPLE,
855 Result.FAIL: _RED,
856 Result.BUG: _YELLOW,
857 Result.NI: _YELLOW,
858 Result.OK: _YELLOW,
859 Result.PASS: _GREEN,
860}
861
862
863def _AnsiCells():
864 lookup = {}
865 for i in xrange(Result.length):
866 lookup[i] = ''.join([ANSI_COLORS[i], _BOLD, TEXT_CELLS[i], _RESET])
867 return lookup
868
869
870ANSI_CELLS = _AnsiCells()
871
872HTML_CELLS = {
873 Result.TIMEOUT: '<td class="timeout">TIME',
874 Result.FAIL: '<td class="fail">FAIL',
875 Result.BUG: '<td class="bug">BUG',
876 Result.NI: '<td class="n-i">N-I',
877 Result.OK: '<td class="ok">ok',
878 Result.PASS: '<td class="pass">pass',
879}
880
881
882def _ValidUtf8String(s):
883 """Return an arbitrary string as a readable utf-8 string.
884
885 We output utf-8 to either HTML or the console. If we get invalid
886 utf-8 as stdout/stderr (which is very possible), then show the ASCII
887 repr().
888 """
889 try:
890 s.decode('utf-8')
891 return s # it decoded OK
892 except UnicodeDecodeError:
893 return repr(s) # ASCII representation
894
895
896class Output(object):
897
898 def __init__(self, f, verbose):
899 self.f = f
900 self.verbose = verbose
901 self.details = []
902
903 def BeginCases(self, test_file):
904 pass
905
906 def WriteHeader(self, sh_labels):
907 pass
908
909 def WriteRow(self, i, line_num, row, desc):
910 pass
911
912 def EndCases(self, sh_labels, stats):
913 pass
914
915 def AddDetails(self, entry):
916 self.details.append(entry)
917
918 # Helper function
919 def _WriteDetailsAsText(self, details):
920 for case_index, shell, stdout, stderr, messages in details:
921 print('case: %d' % case_index, file=self.f)
922 for m in messages:
923 print(m, file=self.f)
924
925 # Assume the terminal can show utf-8, but we don't want random binary.
926 print('%s stdout:' % shell, file=self.f)
927 print(_ValidUtf8String(stdout), file=self.f)
928
929 print('%s stderr:' % shell, file=self.f)
930 print(_ValidUtf8String(stderr), file=self.f)
931
932 print('', file=self.f)
933
934
935class TeeOutput(object):
936 """For multiple outputs in one run, e.g. HTML and TSV.
937
938 UNUSED
939 """
940
941 def __init__(self, outs):
942 self.outs = outs
943
944 def BeginCases(self, test_file):
945 for out in self.outs:
946 out.BeginCases(test_file)
947
948 def WriteHeader(self, sh_labels):
949 for out in self.outs:
950 out.WriteHeader(sh_labels)
951
952 def WriteRow(self, i, line_num, row, desc):
953 for out in self.outs:
954 out.WriteRow(i, line_num, row, desc)
955
956 def EndCases(self, sh_labels, stats):
957 for out in self.outs:
958 out.EndCases(sh_labels, stats)
959
960 def AddDetails(self, entry):
961 for out in self.outs:
962 out.AddDetails(entry)
963
964
965class TsvOutput(Output):
966 """Write a plain-text TSV file.
967
968 UNUSED since we are outputting LONG format with --tsv-output.
969 """
970
971 def WriteHeader(self, sh_labels):
972 self.f.write('case\tline\t') # case number and line number
973 for sh_label in sh_labels:
974 self.f.write(sh_label)
975 self.f.write('\t')
976 self.f.write('\n')
977
978 def WriteRow(self, i, line_num, row, desc):
979 self.f.write('%3d\t%3d\t' % (i, line_num))
980
981 for result in row:
982 c = TEXT_CELLS[result]
983 self.f.write(c)
984 self.f.write('\t')
985
986 # note: 'desc' could use TSV8, but just ignore it for now
987 #self.f.write(desc)
988 self.f.write('\n')
989
990
991class AnsiOutput(Output):
992
993 def BeginCases(self, test_file):
994 self.f.write('%s\n' % test_file)
995
996 def WriteHeader(self, sh_labels):
997 self.f.write(_BOLD)
998 self.f.write('case\tline\t') # case number and line number
999 for sh_label in sh_labels:
1000 self.f.write(sh_label)
1001 self.f.write('\t')
1002 self.f.write(_RESET)
1003 self.f.write('\n')
1004
1005 def WriteRow(self, i, line_num, row, desc):
1006 self.f.write('%3d\t%3d\t' % (i, line_num))
1007
1008 for result in row:
1009 c = ANSI_CELLS[result]
1010 self.f.write(c)
1011 self.f.write('\t')
1012
1013 self.f.write(desc)
1014 self.f.write('\n')
1015
1016 if self.verbose:
1017 self._WriteDetailsAsText(self.details)
1018 self.details = []
1019
1020 def _WriteShellSummary(self, sh_labels, stats):
1021 if len(stats.nonzero_results) <= 1: # Skip trivial summaries
1022 return
1023
1024 # Reiterate header
1025 self.f.write(_BOLD)
1026 self.f.write('\t\t')
1027 for sh_label in sh_labels:
1028 self.f.write(sh_label)
1029 self.f.write('\t')
1030 self.f.write(_RESET)
1031 self.f.write('\n')
1032
1033 # Write totals by cell.
1034 for result in sorted(stats.nonzero_results, reverse=True):
1035 self.f.write('\t%s' % ANSI_CELLS[result])
1036 for sh_label in sh_labels:
1037 self.f.write('\t%d' % stats.by_shell[sh_label][result])
1038 self.f.write('\n')
1039
1040 # The bottom row is all the same, but it helps readability.
1041 self.f.write('\ttotal')
1042 for sh_label in sh_labels:
1043 self.f.write('\t%d' % stats.counters['num_cases_run'])
1044 self.f.write('\n')
1045 self.f.write('\n')
1046
1047 def EndCases(self, sh_labels, stats):
1048 print()
1049 self._WriteShellSummary(sh_labels, stats)
1050
1051
1052class HtmlOutput(Output):
1053
1054 def __init__(self, f, verbose, spec_name, sh_labels, cases):
1055 Output.__init__(self, f, verbose)
1056 self.spec_name = spec_name
1057 self.sh_labels = sh_labels # saved from header
1058 self.cases = cases # for linking to code
1059 self.row_html = [] # buffered
1060
1061 def _SourceLink(self, line_num, desc):
1062 return '<a href="%s.test.html#L%d">%s</a>' % (self.spec_name, line_num,
1063 cgi.escape(desc))
1064
1065 def BeginCases(self, test_file):
1066 css_urls = ['../../../web/base.css', '../../../web/spec-tests.css']
1067 title = '%s: spec test case results' % self.spec_name
1068 html_head.Write(self.f, title, css_urls=css_urls)
1069
1070 self.f.write('''\
1071 <body class="width60">
1072 <p id="home-link">
1073 <a href=".">spec test index</a>
1074 /
1075 <a href="/">oils.pub</a>
1076 </p>
1077 <h1>Results for %s</h1>
1078 <table>
1079 ''' % test_file)
1080
1081 def _WriteShellSummary(self, sh_labels, stats):
1082 # NOTE: This table has multiple <thead>, which seems OK.
1083 self.f.write('''
1084<thead>
1085 <tr class="table-header">
1086 ''')
1087
1088 columns = ['status'] + sh_labels + ['']
1089 for c in columns:
1090 self.f.write('<td>%s</td>' % c)
1091
1092 self.f.write('''
1093 </tr>
1094</thead>
1095''')
1096
1097 # Write totals by cell.
1098 for result in sorted(stats.nonzero_results, reverse=True):
1099 self.f.write('<tr>')
1100
1101 self.f.write(HTML_CELLS[result])
1102 self.f.write('</td> ')
1103
1104 for sh_label in sh_labels:
1105 self.f.write('<td>%d</td>' % stats.by_shell[sh_label][result])
1106
1107 self.f.write('<td></td>')
1108 self.f.write('</tr>\n')
1109
1110 # The bottom row is all the same, but it helps readability.
1111 self.f.write('<tr>')
1112 self.f.write('<td>total</td>')
1113 for sh_label in sh_labels:
1114 self.f.write('<td>%d</td>' % stats.counters['num_cases_run'])
1115 self.f.write('<td></td>')
1116 self.f.write('</tr>\n')
1117
1118 # Blank row for space.
1119 self.f.write('<tr>')
1120 for i in xrange(len(sh_labels) + 2):
1121 self.f.write('<td style="height: 2em"></td>')
1122 self.f.write('</tr>\n')
1123
1124 def WriteHeader(self, sh_labels):
1125 f = cStringIO.StringIO()
1126
1127 f.write('''
1128<thead>
1129 <tr class="table-header">
1130 ''')
1131
1132 columns = ['case'] + sh_labels
1133 for c in columns:
1134 f.write('<td>%s</td>' % c)
1135 f.write('<td class="case-desc">description</td>')
1136
1137 f.write('''
1138 </tr>
1139</thead>
1140''')
1141
1142 self.row_html.append(f.getvalue())
1143
1144 def WriteRow(self, i, line_num, row, desc):
1145 f = cStringIO.StringIO()
1146 f.write('<tr>')
1147 f.write('<td>%3d</td>' % i)
1148
1149 show_details = False
1150
1151 for result in row:
1152 c = HTML_CELLS[result]
1153 if result not in (Result.PASS, Result.TIMEOUT): # nothing to show
1154 show_details = True
1155
1156 f.write(c)
1157 f.write('</td>')
1158 f.write('\t')
1159
1160 f.write('<td class="case-desc">')
1161 f.write(self._SourceLink(line_num, desc))
1162 f.write('</td>')
1163 f.write('</tr>\n')
1164
1165 # Show row with details link.
1166 if show_details:
1167 f.write('<tr>')
1168 f.write('<td class="details-row"></td>') # for the number
1169
1170 for col_index, result in enumerate(row):
1171 f.write('<td class="details-row">')
1172 if result != Result.PASS:
1173 sh_label = self.sh_labels[col_index]
1174 f.write('<a href="#details-%s-%s">details</a>' %
1175 (i, sh_label))
1176 f.write('</td>')
1177
1178 f.write('<td class="details-row"></td>') # for the description
1179 f.write('</tr>\n')
1180
1181 self.row_html.append(f.getvalue()) # buffer it
1182
1183 def _WriteStats(self, stats):
1184 self.f.write('%(num_passed)d passed, %(num_ok)d OK, '
1185 '%(num_ni)d not implemented, %(num_bug)d BUG, '
1186 '%(num_failed)d failed, %(num_timeout)d timeouts, '
1187 '%(num_skipped)d cases skipped\n' % stats.counters)
1188
1189 def EndCases(self, sh_labels, stats):
1190 self._WriteShellSummary(sh_labels, stats)
1191
1192 # Write all the buffered rows
1193 for h in self.row_html:
1194 self.f.write(h)
1195
1196 self.f.write('</table>\n')
1197 self.f.write('<pre>')
1198 self._WriteStats(stats)
1199 if stats.Get('oils_num_failed'):
1200 self.f.write('%(oils_num_failed)d failed under osh\n' %
1201 stats.counters)
1202 self.f.write('</pre>')
1203
1204 if self.details:
1205 self._WriteDetails()
1206
1207 self.f.write('</body></html>')
1208
1209 def _WriteDetails(self):
1210 self.f.write("<h2>Details on runs that didn't PASS</h2>")
1211 self.f.write('<table id="details">')
1212
1213 for case_index, sh_label, stdout, stderr, messages in self.details:
1214 self.f.write('<tr>')
1215 self.f.write('<td><a name="details-%s-%s"></a><b>%s</b></td>' %
1216 (case_index, sh_label, sh_label))
1217
1218 self.f.write('<td>')
1219
1220 # Write description and link to the code
1221 case = self.cases[case_index]
1222 line_num = case['line_num']
1223 desc = case['desc']
1224 self.f.write('%d ' % case_index)
1225 self.f.write(self._SourceLink(line_num, desc))
1226 self.f.write('<br/><br/>\n')
1227
1228 for m in messages:
1229 self.f.write('<span class="assertion">%s</span><br/>\n' %
1230 cgi.escape(m))
1231 if messages:
1232 self.f.write('<br/>\n')
1233
1234 def _WriteRaw(s):
1235 self.f.write('<pre>')
1236
1237 # stdout might contain invalid utf-8; make it valid;
1238 valid_utf8 = _ValidUtf8String(s)
1239
1240 self.f.write(cgi.escape(valid_utf8))
1241 self.f.write('</pre>')
1242
1243 self.f.write('<i>stdout:</i> <br/>\n')
1244 _WriteRaw(stdout)
1245
1246 self.f.write('<i>stderr:</i> <br/>\n')
1247 _WriteRaw(stderr)
1248
1249 self.f.write('</td>')
1250 self.f.write('</tr>')
1251
1252 self.f.write('</table>')
1253
1254
1255def MakeTestEnv(opts):
1256 if not opts.tmp_env:
1257 raise RuntimeError('--tmp-env required')
1258 if not opts.path_env:
1259 raise RuntimeError('--path-env required')
1260 env = {
1261 'PATH': opts.path_env,
1262 #'LANG': opts.lang_env,
1263 }
1264 for p in opts.env_pair:
1265 name, value = p.split('=', 1)
1266 env[name] = value
1267
1268 return env
1269
1270
1271def _DefaultSuite(spec_name):
1272 if spec_name.startswith('ysh-'):
1273 suite = 'ysh'
1274 elif spec_name.startswith('hay'): # hay.test.sh is ysh
1275 suite = 'ysh'
1276
1277 elif spec_name.startswith('tea-'):
1278 suite = 'tea'
1279 else:
1280 suite = 'osh'
1281
1282 return suite
1283
1284
1285def ParseTestList(test_files):
1286 for test_file in test_files:
1287 with open(test_file) as f:
1288 tokens = Tokenizer(f)
1289 try:
1290 file_metadata, cases = ParseTestFile(test_file, tokens)
1291 except RuntimeError as e:
1292 log('ERROR in %r', test_file)
1293 raise
1294 except ParseError as e:
1295 log('PARSE ERROR in %r', test_file)
1296 raise
1297
1298 tmp = os.path.basename(test_file)
1299 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1300
1301 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1302
1303 tmp = file_metadata.get('tags')
1304 tags = tmp.split() if tmp else []
1305
1306 # Don't need compare_shells, etc. to decide what to run
1307
1308 row = {'spec_name': spec_name, 'suite': suite, 'tags': tags}
1309 #print(row)
1310 yield row
1311
1312
1313def main(argv):
1314 # First check if bash is polluting the environment. Tests rely on the
1315 # environment.
1316 v = os.getenv('RANDOM')
1317 if v is not None:
1318 raise AssertionError('got $RANDOM = %s' % v)
1319 v = os.getenv('PPID')
1320 if v is not None:
1321 raise AssertionError('got $PPID = %s' % v)
1322
1323 p = optparse.OptionParser('%s [options] TEST_FILE shell...' % sys.argv[0])
1324 spec_lib.DefineCommon(p)
1325 spec_lib.DefineShSpec(p)
1326 opts, argv = p.parse_args(argv)
1327
1328 # --print-tagged to figure out what to run
1329 if opts.print_tagged:
1330 to_find = opts.print_tagged
1331 for row in ParseTestList(argv[1:]):
1332 if to_find in row['tags']:
1333 print(row['spec_name'])
1334 return 0
1335
1336 # --print-table to figure out what to run
1337 if opts.print_table:
1338 for row in ParseTestList(argv[1:]):
1339 print('%(suite)s\t%(spec_name)s' % row)
1340 #print(row)
1341 return 0
1342
1343 #
1344 # Now deal with a single file
1345 #
1346
1347 try:
1348 test_file = argv[1]
1349 except IndexError:
1350 p.print_usage()
1351 return 1
1352
1353 with open(test_file) as f:
1354 tokens = Tokenizer(f)
1355 file_metadata, cases = ParseTestFile(test_file, tokens)
1356
1357 # List test cases and return
1358 if opts.do_list:
1359 for i, case in enumerate(cases):
1360 if opts.verbose: # print the raw dictionary for debugging
1361 print(pprint.pformat(case))
1362 else:
1363 print('%d\t%s' % (i, case['desc']))
1364 return 0
1365
1366 # for test/spec-cpp.sh
1367 if opts.print_spec_suite:
1368 tmp = os.path.basename(test_file)
1369 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1370
1371 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1372 print(suite)
1373 return 0
1374
1375 if opts.verbose:
1376 for k, v in file_metadata.items():
1377 print('\t%-20s: %s' % (k, v), file=sys.stderr)
1378 print('', file=sys.stderr)
1379
1380 if opts.oils_bin_dir:
1381
1382 shells = []
1383
1384 if opts.compare_shells:
1385 comp = file_metadata.get('compare_shells')
1386 # Compare 'compare_shells' and Python
1387 shells.extend(comp.split() if comp else [])
1388
1389 # Always run with the Python version
1390 our_shell = file_metadata.get('our_shell', 'osh') # default is OSH
1391 if our_shell != '-':
1392 shells.append(os.path.join(opts.oils_bin_dir, our_shell))
1393
1394 # Legacy OVM/CPython build
1395 if opts.ovm_bin_dir:
1396 shells.append(os.path.join(opts.ovm_bin_dir, our_shell))
1397
1398 # New C++ build
1399 if opts.oils_cpp_bin_dir:
1400 shells.append(os.path.join(opts.oils_cpp_bin_dir, our_shell))
1401
1402 # Overwrite it when --oils-bin-dir is set
1403 # It's no longer a flag
1404 opts.oils_failures_allowed = \
1405 int(file_metadata.get('oils_failures_allowed', 0))
1406
1407 else:
1408 # TODO: remove this mode?
1409 shells = argv[2:]
1410
1411 shell_pairs = spec_lib.MakeShellPairs(shells)
1412
1413 if opts.range:
1414 begin, end = spec_lib.ParseRange(opts.range)
1415 case_predicate = spec_lib.RangePredicate(begin, end)
1416 elif opts.regex:
1417 desc_re = re.compile(opts.regex, re.IGNORECASE)
1418 case_predicate = spec_lib.RegexPredicate(desc_re)
1419 else:
1420 case_predicate = lambda i, case: True
1421
1422 out_f = sys.stderr if opts.do_print else sys.stdout
1423
1424 # Set up output style. Also see asdl/format.py
1425 if opts.format == 'ansi':
1426 out = AnsiOutput(out_f, opts.verbose)
1427
1428 elif opts.format == 'html':
1429 spec_name = os.path.basename(test_file)
1430 spec_name = spec_name.split('.')[0]
1431
1432 sh_labels = [label for label, _ in shell_pairs]
1433
1434 out = HtmlOutput(out_f, opts.verbose, spec_name, sh_labels, cases)
1435
1436 else:
1437 raise AssertionError()
1438
1439 out.BeginCases(os.path.basename(test_file))
1440
1441 env = MakeTestEnv(opts)
1442 stats = RunCases(cases, case_predicate, shell_pairs, env, out, opts)
1443
1444 out.EndCases([sh_label for sh_label, _ in shell_pairs], stats)
1445
1446 if opts.tsv_output:
1447 with open(opts.tsv_output, 'w') as f:
1448 stats.WriteTsv(f)
1449
1450 # TODO: Could --stats-{file,template} be a separate awk step on .tsv files?
1451 stats.Set('oils_failures_allowed', opts.oils_failures_allowed)
1452
1453 # If it's not set separately for C++, we default to the allowed number
1454 # above
1455 x = int(
1456 file_metadata.get('oils_cpp_failures_allowed',
1457 opts.oils_failures_allowed))
1458 stats.Set('oils_cpp_failures_allowed', x)
1459
1460 if opts.stats_file:
1461 with open(opts.stats_file, 'w') as f:
1462 f.write(opts.stats_template % stats.counters)
1463 f.write('\n') # bash 'read' requires a newline
1464
1465 # spec/smoke.test.sh -> smoke
1466 test_name = os.path.basename(test_file).split('.')[0]
1467
1468 return _SuccessOrFailure(test_name, stats)
1469
1470
1471def _SuccessOrFailure(test_name, stats):
1472 allowed = stats.Get('oils_failures_allowed')
1473 allowed_cpp = stats.Get('oils_cpp_failures_allowed')
1474
1475 all_count = stats.Get('num_failed')
1476 oils_count = stats.Get('oils_num_failed')
1477 oils_cpp_count = stats.Get('oils_cpp_num_failed')
1478
1479 errors = []
1480 if oils_count != allowed:
1481 errors.append('Got %d Oils failures, but %d are allowed' %
1482 (oils_count, allowed))
1483 else:
1484 if allowed != 0:
1485 log('%s: note: Got %d allowed Oils failures', test_name, allowed)
1486
1487 # TODO: remove special case for 0
1488 if oils_cpp_count != 0:
1489 if oils_cpp_count != allowed_cpp:
1490 errors.append('Got %d Oils C++ failures, but %d are allowed' %
1491 (oils_cpp_count, allowed_cpp))
1492 else:
1493 if allowed_cpp != 0:
1494 log('%s: note: Got %d allowed Oils C++ failures', test_name,
1495 allowed_cpp)
1496
1497 if all_count != allowed:
1498 errors.append('Got %d total failures, but %d are allowed' %
1499 (all_count, allowed))
1500
1501 if errors:
1502 for msg in errors:
1503 log('%s: FATAL: %s', test_name, msg)
1504 return 1
1505
1506 return 0
1507
1508
1509if __name__ == '__main__':
1510 try:
1511 sys.exit(main(sys.argv))
1512 except KeyboardInterrupt as e:
1513 print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
1514 sys.exit(1)
1515 except RuntimeError as e:
1516 print('FATAL: %s' % e, file=sys.stderr)
1517 sys.exit(1)